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
207 lines
8.8 KiB
Python
207 lines
8.8 KiB
Python
from fastapi import HTTPException, status
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.chats import repository
|
|
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
|
from app.chats.schemas import ChatCreateRequest, ChatTitleUpdateRequest
|
|
from app.users.repository import get_user_by_id
|
|
|
|
|
|
async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat:
|
|
member_ids = list(dict.fromkeys(payload.member_ids))
|
|
member_ids = [member_id for member_id in member_ids if member_id != creator_id]
|
|
|
|
if payload.type == ChatType.PRIVATE and len(member_ids) != 1:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Private chat requires exactly one target user.",
|
|
)
|
|
if payload.type == ChatType.PRIVATE:
|
|
existing_chat = await repository.find_private_chat_between_users(
|
|
db,
|
|
user_a_id=creator_id,
|
|
user_b_id=member_ids[0],
|
|
)
|
|
if existing_chat:
|
|
return existing_chat
|
|
if payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.title:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Group and channel chats require title.",
|
|
)
|
|
|
|
for member_id in member_ids:
|
|
user = await get_user_by_id(db, member_id)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {member_id} not found")
|
|
|
|
chat = await repository.create_chat(db, chat_type=payload.type, title=payload.title)
|
|
await repository.add_chat_member(db, chat_id=chat.id, user_id=creator_id, role=ChatMemberRole.OWNER)
|
|
|
|
default_role = ChatMemberRole.MEMBER
|
|
for member_id in member_ids:
|
|
await repository.add_chat_member(db, chat_id=chat.id, user_id=member_id, role=default_role)
|
|
|
|
await db.commit()
|
|
return chat
|
|
|
|
|
|
async def get_chats_for_user(db: AsyncSession, *, user_id: int, limit: int = 50, before_id: int | None = None) -> list[Chat]:
|
|
safe_limit = max(1, min(limit, 100))
|
|
return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id)
|
|
|
|
|
|
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:
|
|
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")
|
|
|
|
members = await repository.list_chat_members(db, chat_id=chat_id)
|
|
return chat, members
|
|
|
|
|
|
async def ensure_chat_membership(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
|
|
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")
|
|
|
|
|
|
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()
|