feat(chats): add role-based member management APIs
All checks were successful
CI / test (push) Successful in 32s
All checks were successful
CI / test (push) Successful in 32s
- add owner/admin/member permission checks - implement member add/remove, role updates, and leave flow - add chat title update endpoint for manageable chat types
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Select, select
|
from sqlalchemy import Select, func, select
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -19,6 +19,15 @@ async def add_chat_member(db: AsyncSession, *, chat_id: int, user_id: int, role:
|
|||||||
return member
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_chat_member(db: AsyncSession, member: ChatMember) -> None:
|
||||||
|
await db.delete(member)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_chat_members(db: AsyncSession, *, chat_id: int) -> int:
|
||||||
|
result = await db.execute(select(func.count(ChatMember.id)).where(ChatMember.chat_id == chat_id))
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _user_chats_query(user_id: int) -> Select[tuple[Chat]]:
|
def _user_chats_query(user_id: int) -> Select[tuple[Chat]]:
|
||||||
return (
|
return (
|
||||||
select(Chat)
|
select(Chat)
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth.service import get_current_user
|
from app.auth.service import get_current_user
|
||||||
from app.chats.schemas import ChatCreateRequest, ChatDetailRead, ChatRead
|
from app.chats.schemas import (
|
||||||
from app.chats.service import create_chat_for_user, get_chat_for_user, get_chats_for_user
|
ChatCreateRequest,
|
||||||
|
ChatDetailRead,
|
||||||
|
ChatMemberAddRequest,
|
||||||
|
ChatMemberRead,
|
||||||
|
ChatMemberRoleUpdateRequest,
|
||||||
|
ChatRead,
|
||||||
|
ChatTitleUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.chats.service import (
|
||||||
|
add_chat_member_for_user,
|
||||||
|
create_chat_for_user,
|
||||||
|
get_chat_for_user,
|
||||||
|
get_chats_for_user,
|
||||||
|
leave_chat_for_user,
|
||||||
|
remove_chat_member_for_user,
|
||||||
|
update_chat_member_role_for_user,
|
||||||
|
update_chat_title_for_user,
|
||||||
|
)
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
from app.users.models import User
|
from app.users.models import User
|
||||||
|
|
||||||
@@ -43,3 +60,69 @@ async def get_chat(
|
|||||||
created_at=chat.created_at,
|
created_at=chat.created_at,
|
||||||
members=members,
|
members=members,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{chat_id}/title", response_model=ChatRead)
|
||||||
|
async def update_chat_title(
|
||||||
|
chat_id: int,
|
||||||
|
payload: ChatTitleUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ChatRead:
|
||||||
|
return await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{chat_id}/members", response_model=list[ChatMemberRead])
|
||||||
|
async def list_chat_members(
|
||||||
|
chat_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> list[ChatMemberRead]:
|
||||||
|
_chat, members = await get_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chat_id}/members", response_model=ChatMemberRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_chat_member(
|
||||||
|
chat_id: int,
|
||||||
|
payload: ChatMemberAddRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ChatMemberRead:
|
||||||
|
return await add_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=payload.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{chat_id}/members/{user_id}/role", response_model=ChatMemberRead)
|
||||||
|
async def update_chat_member_role(
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
payload: ChatMemberRoleUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ChatMemberRead:
|
||||||
|
return await update_chat_member_role_for_user(
|
||||||
|
db,
|
||||||
|
chat_id=chat_id,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
target_user_id=user_id,
|
||||||
|
role=payload.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{chat_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_chat_member(
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await remove_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def leave_chat(
|
||||||
|
chat_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||||
|
|||||||
@@ -31,3 +31,15 @@ class ChatCreateRequest(BaseModel):
|
|||||||
type: ChatType
|
type: ChatType
|
||||||
title: str | None = Field(default=None, max_length=255)
|
title: str | None = Field(default=None, max_length=255)
|
||||||
member_ids: list[int] = Field(default_factory=list)
|
member_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMemberAddRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMemberRoleUpdateRequest(BaseModel):
|
||||||
|
role: ChatMemberRole
|
||||||
|
|
||||||
|
|
||||||
|
class ChatTitleUpdateRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.chats import repository
|
from app.chats import repository
|
||||||
from app.chats.models import Chat, ChatMemberRole, ChatType
|
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
||||||
from app.chats.schemas import ChatCreateRequest
|
from app.chats.schemas import ChatCreateRequest, ChatTitleUpdateRequest
|
||||||
from app.users.repository import get_user_by_id
|
from app.users.repository import get_user_by_id
|
||||||
|
|
||||||
|
|
||||||
@@ -68,3 +69,138 @@ async def ensure_chat_membership(db: AsyncSession, *, chat_id: int, user_id: int
|
|||||||
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
|
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
|
||||||
if not membership:
|
if not membership:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_chat_and_membership(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, ChatMember]:
|
||||||
|
chat = await repository.get_chat_by_id(db, chat_id)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||||
|
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
||||||
|
return chat, membership
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_manage_permission(role: ChatMemberRole) -> None:
|
||||||
|
if role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_group_or_channel(chat_type: ChatType) -> None:
|
||||||
|
if chat_type == ChatType.PRIVATE:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be managed")
|
||||||
|
|
||||||
|
|
||||||
|
async def update_chat_title_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
payload: ChatTitleUpdateRequest,
|
||||||
|
) -> Chat:
|
||||||
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
_ensure_manage_permission(membership.role)
|
||||||
|
chat.title = payload.title
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(chat)
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def add_chat_member_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
actor_user_id: int,
|
||||||
|
target_user_id: int,
|
||||||
|
) -> ChatMember:
|
||||||
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
_ensure_manage_permission(membership.role)
|
||||||
|
if target_user_id == actor_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat")
|
||||||
|
target_user = await get_user_by_id(db, target_user_id)
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
existing = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
try:
|
||||||
|
member = await repository.add_chat_member(
|
||||||
|
db,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=target_user_id,
|
||||||
|
role=ChatMemberRole.MEMBER,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(member)
|
||||||
|
return member
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
existing = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def update_chat_member_role_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
actor_user_id: int,
|
||||||
|
target_user_id: int,
|
||||||
|
role: ChatMemberRole,
|
||||||
|
) -> ChatMember:
|
||||||
|
chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
if actor_membership.role != ChatMemberRole.OWNER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner can change roles")
|
||||||
|
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
|
||||||
|
if not target_membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target member not found")
|
||||||
|
if target_membership.role == ChatMemberRole.OWNER and target_user_id != actor_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot change owner role")
|
||||||
|
if target_user_id == actor_user_id and role != ChatMemberRole.OWNER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot demote self")
|
||||||
|
target_membership.role = role
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target_membership)
|
||||||
|
return target_membership
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_chat_member_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
actor_user_id: int,
|
||||||
|
target_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
|
||||||
|
if not target_membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target member not found")
|
||||||
|
if target_membership.role == ChatMemberRole.OWNER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be removed")
|
||||||
|
if actor_user_id == target_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Use leave endpoint")
|
||||||
|
if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can remove only members")
|
||||||
|
if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
await repository.delete_chat_member(db, target_membership)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
|
||||||
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
if membership.role == ChatMemberRole.OWNER:
|
||||||
|
members_count = await repository.count_chat_members(db, chat_id=chat_id)
|
||||||
|
if members_count > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Owner cannot leave while chat has other members",
|
||||||
|
)
|
||||||
|
await repository.delete_chat_member(db, membership)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user