feat: add 13 changes

This commit is contained in:
roai_linux 2026-03-07 19:09:49 +03:30
parent bedecb82bd
commit a99d920f2c
22 changed files with 573 additions and 102 deletions

View File

@ -51,6 +51,14 @@ async def get_current_user(
detail="User is inactive",
)
# Check token version for remote logout
token_version = payload.get("token_version")
if token_version != user.token_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been invalidated",
)
return user
@ -61,7 +69,7 @@ async def get_current_admin(
Ensure the authenticated user is an admin
"""
if user.role != "admin":
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",

View File

@ -6,6 +6,7 @@ from core.config import settings
def create_access_token(
subject: str,
token_version: int,
expires_delta: timedelta | None = None,
) -> str:
@ -18,6 +19,7 @@ def create_access_token(
payload = {
"sub": subject,
"token_version": token_version,
"exp": expire,
}

View File

@ -38,7 +38,8 @@ async def create_user(
user, secret = await admin_create_user(
db,
payload.username,
payload.role
payload.phone_number,
payload.is_admin
)
except ValueError as e:
@ -54,6 +55,22 @@ async def create_user(
}
@router.post("/users/{user_id}/logout")
async def logout_user(
user_id: str,
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
):
from domains.admin.service import admin_logout_user
user = await admin_logout_user(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return {"message": "User logged out successfully"}
@router.post("/users/{user_id}/reset-secret",
response_model=AdminResetSecretResult)
async def reset_secret(

View File

@ -1,15 +1,16 @@
import uuid
from pydantic import BaseModel
from domains.users.models import UserRole
class AdminCreateUser(BaseModel):
username: str
role: UserRole
phone_number: str | None = None
is_admin: bool = False
class AdminUserResponse(BaseModel):
id: uuid.UUID
username: str
role: UserRole
phone_number: str | None
is_admin: bool
is_active: bool
class Config:

View File

@ -20,7 +20,8 @@ def generate_user_secret():
async def admin_create_user(
db: AsyncSession,
username: str,
role: str
phone_number: str | None = None,
is_admin: bool = False
):
existing = await get_user_by_username(db, username)
@ -32,7 +33,8 @@ async def admin_create_user(
user = User(
username=username,
role=role,
phone_number=phone_number,
is_admin=is_admin,
secret_hash=hash_password(secret)
)
@ -41,6 +43,19 @@ async def admin_create_user(
return user, secret
async def admin_logout_user(
db: AsyncSession,
user_id: str
):
user = await get_user_by_id(db, user_id)
if not user:
return None
user.token_version += 1
await db.commit()
return user
async def admin_reset_user_secret(
db: AsyncSession,
user_id

View File

@ -15,7 +15,7 @@ class TokenResponse(BaseModel):
class AuthUser(BaseModel):
id: uuid.UUID
username: str
role: str
is_admin: bool
class Config:
from_attributes = True

View File

@ -44,7 +44,8 @@ async def login_user(
return None
token = create_access_token(
subject=str(user.id)
subject=str(user.id),
token_version=user.token_version
)
return {

View File

@ -2,17 +2,22 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db
from core.deps import get_current_admin
from core.deps import get_current_admin, get_current_user
from domains.groups.schemas import (
GroupCreate,
GroupResponse,
AddMemberRequest
AddMemberRequest,
GroupMemberResponse
)
from domains.groups.service import (
create_new_group,
add_member_to_group,
list_user_groups,
list_all_groups_admin,
list_group_members_api,
invite_member_to_group,
remove_member_from_group
)
@ -29,35 +34,100 @@ router = APIRouter(
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
user=Depends(get_current_user)
):
"""
Admin creates Public groups, Regular users create Private groups.
"""
group = await create_new_group(
db,
payload.name
payload.name,
user.id,
user.is_admin
)
return group
@router.post("/{group_id}/members")
async def add_member(
group_id: str,
payload: AddMemberRequest,
@router.get("/my", response_model=list[GroupResponse])
async def my_groups(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
"""
List groups the user is a member of.
"""
return await list_user_groups(db, user.id)
@router.get("/admin/all", response_model=list[GroupResponse])
async def list_all_groups(
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
):
"""
Admin can see all groups.
"""
return await list_all_groups_admin(db)
@router.get("/{group_id}/members", response_model=list[GroupMemberResponse])
async def group_members(
group_id: str,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
"""
List group members with username and online status.
"""
return await list_group_members_api(db, group_id)
@router.post("/{group_id}/invite")
async def invite_member(
group_id: str,
payload: AddMemberRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
"""
Invite a user by username. Sends a notification.
"""
try:
membership = await add_member_to_group(
notification = await invite_member_to_group(
db,
group_id,
payload.user_id,
user.id,
payload.username
)
return {"message": "Invitation sent", "notification_id": notification.id}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
return membership
@router.delete("/{group_id}/members/{user_id}")
async def remove_member(
group_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
"""
Admin or Group Manager can remove a member.
"""
try:
await remove_member_from_group(
db,
group_id,
user_id,
user
)
return {"message": "Member removed successfully"}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e)
)

View File

@ -3,14 +3,20 @@ from enum import Enum
from sqlalchemy import String, Boolean, ForeignKey, Enum as SQLEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from domains.users.models import User
from db.base import Base
class GroupType(str, Enum):
GROUP = "group"
DIRECT = "direct"
PUBLIC = "public"
PRIVATE = "private"
class GroupMemberRole(str, Enum):
MANAGER = "manager"
MEMBER = "member"
class Group(Base):
__tablename__ = "groups" # type: ignore
@ -23,7 +29,7 @@ class Group(Base):
type: Mapped[GroupType] = mapped_column(
SQLEnum(GroupType, name="group_type"),
default=GroupType.GROUP,
default=GroupType.PUBLIC,
nullable=False
)
@ -33,6 +39,9 @@ class Group(Base):
index=True
)
# Relationship to members
members: Mapped[list["GroupMember"]] = relationship(back_populates="group", cascade="all, delete-orphan")
class GroupMember(Base):
__tablename__ = "group_members" # type: ignore
@ -46,3 +55,13 @@ class GroupMember(Base):
ForeignKey("groups.id", ondelete="CASCADE"),
index=True
)
role: Mapped[GroupMemberRole] = mapped_column(
SQLEnum(GroupMemberRole, name="group_member_role"),
default=GroupMemberRole.MEMBER,
nullable=False
)
# Relationships
group: Mapped["Group"] = relationship(back_populates="members")
user: Mapped["User"] = relationship()

View File

@ -1,4 +1,5 @@
from sqlalchemy import select
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from domains.groups.models import Group, GroupMember
@ -31,6 +32,42 @@ async def get_user_groups(db: AsyncSession, user_id):
return result.scalars().all()
async def get_groups_by_type(db: AsyncSession, group_type):
result = await db.execute(
select(Group).where(Group.type == group_type)
)
return result.scalars().all()
async def get_all_groups(db: AsyncSession):
result = await db.execute(select(Group))
return result.scalars().all()
async def get_group_members_with_details(db: AsyncSession, group_id: uuid.UUID):
from domains.users.models import User
result = await db.execute(
select(GroupMember, User.username)
.join(User, GroupMember.user_id == User.id)
.where(GroupMember.group_id == group_id)
)
return result.all()
async def get_group_member(db: AsyncSession, group_id: uuid.UUID, user_id: uuid.UUID):
result = await db.execute(
select(GroupMember)
.where(GroupMember.group_id == group_id)
.where(GroupMember.user_id == user_id)
)
return result.scalar_one_or_none()
async def delete_group_member(db: AsyncSession, group_id: uuid.UUID, user_id: uuid.UUID):
from sqlalchemy import delete
await db.execute(
delete(GroupMember)
.where(GroupMember.group_id == group_id)
.where(GroupMember.user_id == user_id)
)
await db.commit()

View File

@ -1,7 +1,7 @@
import uuid
from pydantic import BaseModel
from domains.groups.models import GroupType, GroupMemberRole
class GroupCreate(BaseModel):
name: str
@ -9,17 +9,20 @@ class GroupCreate(BaseModel):
class GroupResponse(BaseModel):
id: uuid.UUID
name: str
type: GroupType
is_active: bool
class Config:
from_attributes = True
class AddMemberRequest(BaseModel):
user_id: uuid.UUID
username: str # Req 12 says user enters username
class GroupMemberResponse(BaseModel):
user_id: uuid.UUID
group_id: uuid.UUID
username: str
role: GroupMemberRole
is_online: bool = False
class Config:
from_attributes = True

View File

@ -1,46 +1,86 @@
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from domains.users.repo import get_user_by_id
from domains.groups.models import Group, GroupMember
from domains.groups.models import Group, GroupMember, GroupType, GroupMemberRole
from domains.groups.repo import (
create_group,
get_group_by_id,
add_group_member,
get_user_groups
get_user_groups,
get_group_members_with_details,
delete_group_member,
get_all_groups as repo_get_all_groups
)
from domains.realtime.presence_service import list_online_users
async def create_new_group(
db: AsyncSession,
name: str,
creator_id,
is_admin: bool
):
group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE
group = Group(
name=name,
type=group_type
)
return await create_group(db, group)
await create_group(db, group)
async def add_member_to_group(
# Creator becomes Manager
membership = GroupMember(
group_id=group.id,
user_id=creator_id,
role=GroupMemberRole.MANAGER
)
await add_group_member(db, membership)
return group
async def invite_member_to_group(
db: AsyncSession,
group_id,
user_id,
sender_id,
target_username: str
):
from domains.users.repo import get_user_by_username
from domains.notifications.service import send_join_request
# 1. Check if group exists
group = await get_group_by_id(db, group_id)
if not group:
raise ValueError("Group not found")
# 2. Check if user exists
user = await get_user_by_id(db, user_id)
if not user:
# 2. Check if target user exists
target_user = await get_user_by_username(db, target_username)
if not target_user:
raise ValueError("User not found")
# TODO: Check if already a member to avoid duplicate constraint if any (optional)
# 3. Send notification (Req 12)
return await send_join_request(
db,
sender_id=str(sender_id),
receiver_id=str(target_user.id),
group_id=str(group.id),
title="Group Invitation",
description=f"You have been invited to join group {group.name}"
)
async def add_member_to_group(
db: AsyncSession,
group_id,
user_id,
role: GroupMemberRole = GroupMemberRole.MEMBER
):
membership = GroupMember(
group_id=group_id,
user_id=user_id,
role=role
)
return await add_group_member(db, membership)
@ -50,3 +90,45 @@ async def list_user_groups(
user_id
):
return await get_user_groups(db, user_id)
async def list_all_groups_admin(db: AsyncSession):
return await repo_get_all_groups(db)
async def list_group_members_api(db: AsyncSession, group_id: str):
group_id_uuid = uuid.UUID(group_id)
members_data = await get_group_members_with_details(db, group_id_uuid)
online_users = await list_online_users(str(group_id))
result = []
for member_info in members_data:
member, username = member_info
result.append({
"user_id": member.user_id,
"username": username,
"role": member.role,
"is_online": str(member.user_id) in online_users
})
return result
async def remove_member_from_group(
db: AsyncSession,
group_id,
target_user_id,
requesting_user
):
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
target_user_id_uuid = target_user_id if isinstance(target_user_id, uuid.UUID) else uuid.UUID(target_user_id)
# Req 13: Admin or Group Manager can remove
if not requesting_user.is_admin:
# Check if requesting user is manager of this group
from domains.groups.repo import get_group_member
membership = await get_group_member(db, group_id_uuid, requesting_user.id)
if not membership or membership.role != GroupMemberRole.MANAGER:
raise ValueError("Permission denied. Only admin or group manager can remove members.")
# For now, let's assume we implement the check here or in repo.
await delete_group_member(db, group_id_uuid, target_user_id_uuid)

View File

View File

@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db
from core.deps import get_current_user, get_current_admin
from domains.notifications.schemas import (
NotificationResponse,
NotificationAction,
NotificationCreate
)
from domains.notifications.service import (
list_my_notifications,
respond_to_notification,
send_public_notification
)
from domains.users.repo import get_all_users
router = APIRouter(
prefix="/notifications",
tags=["notifications"]
)
@router.get("/", response_model=list[NotificationResponse])
async def my_notifications(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
return await list_my_notifications(db, user.id)
@router.post("/{notification_id}/respond", response_model=NotificationResponse)
async def respond_notification(
notification_id: str,
payload: NotificationAction,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
try:
return await respond_to_notification(
db,
notification_id,
user.id,
payload.is_accepted
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/public")
async def broadcast_notification(
title: str,
description: str,
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
):
users = await get_all_users(db)
user_ids = [str(u.id) for u in users]
return await send_public_notification(
db,
title,
description,
admin.id,
user_ids
)

View File

@ -0,0 +1,42 @@
import uuid
from enum import Enum
from sqlalchemy import String, Boolean, ForeignKey, Enum as SQLEnum, Text
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
class NotificationType(str, Enum):
PUBLIC = "public"
JOIN_REQUEST = "join_request"
class Notification(Base):
__tablename__ = "notifications" # type: ignore
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
type: Mapped[NotificationType] = mapped_column(
SQLEnum(NotificationType, name="notification_type"),
nullable=False
)
is_accepted: Mapped[bool | None] = mapped_column(
Boolean,
nullable=True,
default=None
)
receiver_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
nullable=False
)
sender_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
group_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"),
nullable=True
)

View File

@ -0,0 +1,28 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from domains.notifications.models import Notification
async def create_notification(db: AsyncSession, notification: Notification):
db.add(notification)
await db.commit()
await db.refresh(notification)
return notification
async def get_notification_by_id(db: AsyncSession, notification_id):
result = await db.execute(
select(Notification).where(Notification.id == notification_id)
)
return result.scalar_one_or_none()
async def get_user_notifications(db: AsyncSession, user_id):
result = await db.execute(
select(Notification)
.where(Notification.receiver_id == user_id)
.order_by(Notification.created_at.desc())
)
return result.scalars().all()
async def update_notification(db: AsyncSession, notification: Notification):
await db.commit()
await db.refresh(notification)
return notification

View File

@ -0,0 +1,25 @@
import uuid
from pydantic import BaseModel
from domains.notifications.models import NotificationType
class NotificationBase(BaseModel):
title: str
description: str | None = None
type: NotificationType
group_id: uuid.UUID | None = None
class NotificationCreate(NotificationBase):
receiver_id: uuid.UUID
sender_id: uuid.UUID | None = None
class NotificationResponse(NotificationBase):
id: uuid.UUID
is_accepted: bool | None
receiver_id: uuid.UUID
sender_id: uuid.UUID | None
class Config:
from_attributes = True
class NotificationAction(BaseModel):
is_accepted: bool

View File

@ -0,0 +1,74 @@
from sqlalchemy.ext.asyncio import AsyncSession
from domains.notifications.models import Notification, NotificationType
from domains.notifications.repo import (
create_notification,
get_user_notifications,
get_notification_by_id,
update_notification
)
async def send_public_notification(
db: AsyncSession,
title: str,
description: str,
sender_id: str,
receiver_ids: list[str]
):
notifications = []
for receiver_id in receiver_ids:
notification = Notification(
title=title,
description=description,
type=NotificationType.PUBLIC,
sender_id=sender_id,
receiver_id=receiver_id
)
db.add(notification)
notifications.append(notification)
await db.commit()
return notifications
async def send_join_request(
db: AsyncSession,
sender_id: str,
receiver_id: str,
group_id: str,
title: str,
description: str | None = None
):
notification = Notification(
title=title,
description=description,
type=NotificationType.JOIN_REQUEST,
sender_id=sender_id,
receiver_id=receiver_id,
group_id=group_id
)
return await create_notification(db, notification)
async def list_my_notifications(db: AsyncSession, user_id):
return await get_user_notifications(db, user_id)
async def respond_to_notification(
db: AsyncSession,
notification_id: str,
user_id: str,
is_accepted: bool
):
notification = await get_notification_by_id(db, notification_id)
if not notification:
raise ValueError("Notification not found")
if str(notification.receiver_id) != str(user_id):
raise ValueError("Permission denied")
notification.is_accepted = is_accepted
await update_notification(db, notification)
# If it's a join request and accepted, add user to group
if notification.type == NotificationType.JOIN_REQUEST and is_accepted:
from domains.groups.service import add_member_to_group
await add_member_to_group(db, notification.group_id, user_id)
return notification

View File

@ -2,11 +2,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db
from core.deps import get_current_admin, get_current_user
from domains.groups.schemas import GroupResponse
from domains.groups.service import list_user_groups
from core.deps import get_current_user
from domains.users.schemas import UserResponse
from domains.users.repo import get_all_users
router = APIRouter(
prefix="/users",
@ -14,9 +13,12 @@ router = APIRouter(
)
@router.get("/me/groups", response_model=list[GroupResponse])
async def my_groups(
@router.get("/", response_model=list[UserResponse])
async def list_users(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
return await list_user_groups(db, user.id)
"""
List all users. Regular users can use this to find people to invite to groups.
"""
return await get_all_users(db)

View File

@ -1,15 +1,9 @@
from enum import Enum
from sqlalchemy import String, Boolean, Enum as SQLEnum
from sqlalchemy import String, Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
class UserRole(str, Enum):
ADMIN = "admin"
GROUP_MANAGER = "group_manager"
MEMBER = "member"
class User(Base):
username: Mapped[str] = mapped_column(
String(50),
@ -23,13 +17,26 @@ class User(Base):
nullable=False,
)
role: Mapped[UserRole] = mapped_column(
SQLEnum(UserRole, name="user_role"),
default=UserRole.MEMBER,
is_admin: Mapped[bool] = mapped_column(
Boolean,
default=False,
index=True,
nullable=False,
)
phone_number: Mapped[str | None] = mapped_column(
String(11),
unique=True,
index=True,
nullable=True,
)
token_version: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,

View File

@ -1,15 +1,27 @@
import uuid
from pydantic import BaseModel
from domains.users.models import UserRole
from pydantic import BaseModel, Field, field_validator
import re
class UserCreate(BaseModel):
username: str
role: UserRole
phone_number: str | None = Field(None, description="11 digit phone number")
is_admin: bool = False
@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, v: str | None) -> str | None:
if v is None:
return v
if not re.match(r"^\d{11}$", v):
raise ValueError("Phone number must be exactly 11 digits")
return v
class UserResponse(BaseModel):
id: uuid.UUID
username: str
role: str
phone_number: str | None
is_admin: bool
is_active: bool
class Config:

View File

@ -1,44 +1,6 @@
import secrets
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password
from domains.users.models import User
from domains.users.repo import create_user
from core.config import settings
def generate_user_secret():
# return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
return "1234"
async def create_user_by_admin(
db: AsyncSession,
username: str,
role: str
):
secret = generate_user_secret()
user = User(
username=username,
role=role,
secret_hash=hash_password(secret)
)
await create_user(db, user)
return user, secret
async def get_user(db: AsyncSession, user_id):
from domains.users.repo import get_user_by_id
async def get_user(db: AsyncSession, user_id):
return await get_user_by_id(db, user_id)
async def reset_user_secret(db: AsyncSession, user: User):
new_secret = generate_user_secret()
user.secret_hash = hash_password(new_secret)
await db.commit()
return new_secret