Files
Messenger/app/chats/service.py
benya 4d704fc279
All checks were successful
CI / test (push) Successful in 24s
feat: add search APIs and telegram-like chats sidebar flow
- implement chat query filtering and message search endpoints

- add db indexes for search fields

- activate chats search input in web

- replace inline create panel with floating TG-style action menu
2026-03-08 00:19:34 +03:00

214 lines
8.9 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,
query: str | 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, query=query)
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()