"""Роутер конференций: создание, «Мои конференции», календарь, резолв, вход, правки (ADR-001).""" import uuid from datetime import UTC, datetime, timedelta from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy.ext.asyncio import AsyncSession from api.deps import get_current_user from core.db import get_session from core.rate_limit import ( RATE_LIMIT_MISS_MAX_REQUESTS, RATE_LIMIT_SOFT_MAX_REQUESTS, client_ip, enforce_rate_limit, ) from models.user import User from schemas.conferences import ( ConferenceCreateIn, ConferenceOut, ConferenceUpdateIn, GuestJoinIn, JoinIn, JoinOut, MuteParticipantIn, MuteParticipantOut, OccurrenceOut, ResolveOut, ) from services.conference_access import ( ConferenceEndedError, InvalidPasswordError, PasswordRequiredError, ) from services.conferences import ( ConferenceActiveError, ConferenceNotFoundError, ConferenceService, InvalidConferenceStateError, InviteeUserNotFoundError, NotConferenceOwnerError, ) from services.room_control import ParticipantNotInRoomError router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"]) # Максимальная ширина диапазона `from`/`to` для GET /calendar — защита от # случайного запроса на годы вперёд (календарь UI показывает недели/месяцы). MAX_CALENDAR_RANGE = timedelta(days=62) @router.post("", status_code=status.HTTP_201_CREATED, response_model=ConferenceOut) async def create_conference( data: ConferenceCreateIn, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ) -> ConferenceOut: """Создать конференцию: без `scheduled_at` — мгновенная (создатель входит сразу же).""" service = ConferenceService(session) try: conference, join = await service.create(owner=user, data=data) except InviteeUserNotFoundError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found" ) from exc return await service.to_detail_out(conference, viewer=user, join=join) @router.get("/my", response_model=list[ConferenceOut]) async def list_my_conferences( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ) -> list[ConferenceOut]: """Закреплённые конференции + предстоящие разовые владельца.""" service = ConferenceService(session) return await service.list_my(owner=user) @router.get("/calendar", response_model=list[OccurrenceOut]) async def get_calendar( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], from_: Annotated[datetime, Query(alias="from")], to: Annotated[datetime, Query()], ) -> list[OccurrenceOut]: """Развёртка вхождений закреплённых (с повторением) и разовых плановых конференций владельца.""" t_from = _require_utc(from_) t_to = _require_utc(to) if t_to <= t_from or t_to - t_from > MAX_CALENDAR_RANGE: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invalid_range" ) service = ConferenceService(session) return await service.list_calendar(owner=user, t_from=t_from, t_to=t_to) @router.get("/resolve", response_model=ResolveOut) async def resolve_conference( request: Request, session: Annotated[AsyncSession, Depends(get_session)], q: Annotated[str, Query(min_length=1)], ) -> ResolveOut: """Найти конференцию по номеру или ссылке — без auth; rate limit; единообразный 404. Для завершённой конференции (`status=ended`) отдаём минимальный ответ — только `id`/`title`/`status`, без `is_closed`/`requires_password` (ADR-001, п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у join/guest-join), а признак закрытости неактуален для мёртвой конференции. """ ip = client_ip(request) # Мягкий потолок против флуда: успешные резолвы легитимны и массовы — # вся конференция открывает ссылку в одну минуту. await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS) service = ConferenceService(session) conference = await service.resolve(q) if conference is None: # Жёсткий счётчик — только на промахи: перебор номера конференции # выглядит именно так (см. core/rate_limit.py и ADR-001, п.4). await enforce_rate_limit( f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS ) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found") if conference.status == "ended": return ResolveOut(id=conference.id, title=conference.title, status=conference.status) return ResolveOut( id=conference.id, title=conference.title, status=conference.status, is_closed=conference.is_closed, requires_password=conference.is_closed, ) @router.post("/{conference_id}/join", response_model=JoinOut) async def join_conference( conference_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], data: JoinIn = JoinIn(), ) -> JoinOut: """Войти в конференцию зарегистрированным пользователем.""" service = ConferenceService(session) try: return await service.join_as_user(conference_id, user=user, password=data.password) except ConferenceNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" ) from exc except ConferenceEndedError as exc: raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc except PasswordRequiredError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="password_required" ) from exc except InvalidPasswordError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" ) from exc @router.post("/{conference_id}/guest-join", response_model=JoinOut) async def guest_join_conference( conference_id: uuid.UUID, request: Request, data: GuestJoinIn, session: Annotated[AsyncSession, Depends(get_session)], ) -> JoinOut: """Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit.""" ip = client_ip(request) # Мягкий потолок: успешный гостевой вход — обычное дело для всей # конференции сразу, ограничивать его числом «10 в минуту» нельзя. await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS) service = ConferenceService(session) try: return await service.join_as_guest(conference_id, data=data) except ConferenceNotFoundError as exc: await _count_guest_join_miss(ip) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" ) from exc except ConferenceEndedError as exc: raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc except PasswordRequiredError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="password_required" ) from exc except InvalidPasswordError as exc: # Подбор пароля закрытой конференции — тот же класс атаки, что и # перебор номера, поэтому считается жёстким счётчиком. await _count_guest_join_miss(ip) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" ) from exc @router.post("/{conference_id}/mute-participant", response_model=MuteParticipantOut) async def mute_participant( conference_id: uuid.UUID, data: MuteParticipantIn, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ) -> MuteParticipantOut: """Принудительно выключить микрофон/камеру участника (задача B2) — владелец/администратор. Права проверяются ЗАНОВО по владельцу конференции в БД (`ConferenceService.mute_participant`), а не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и потенциально подделываемы клиентом. """ service = ConferenceService(session) try: muted = await service.mute_participant( conference_id, actor=user, target_identity=data.identity, source=data.source ) except ConferenceNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" ) from exc except NotConferenceOwnerError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc except ParticipantNotInRoomError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="participant_not_in_room" ) from exc return MuteParticipantOut(muted=muted) @router.get("/{conference_id}", response_model=ConferenceOut) async def get_conference( conference_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ) -> ConferenceOut: """Детальная карточка конференции (с полным составом участников); владелец или администратор.""" service = ConferenceService(session) try: conference = await service.get_detail(conference_id, actor=user) except ConferenceNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" ) from exc except NotConferenceOwnerError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc return await service.to_detail_out(conference, viewer=user) @router.patch("/{conference_id}", response_model=ConferenceOut) async def update_conference( conference_id: uuid.UUID, data: ConferenceUpdateIn, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ) -> ConferenceOut: """Изменить конференцию: разрешено владельцу или администратору.""" service = ConferenceService(session) try: conference = await service.update(conference_id, actor=user, data=data) except ConferenceNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" ) from exc except NotConferenceOwnerError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc except InvalidConferenceStateError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc except InviteeUserNotFoundError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found" ) from exc return await service.to_detail_out(conference, viewer=user) @router.delete("/{conference_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_conference( conference_id: uuid.UUID, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ) -> None: """Удалить конференцию: запрещено для активной (409), разрешено владельцу/администратору.""" service = ConferenceService(session) try: await service.delete(conference_id, actor=user) except ConferenceNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" ) from exc except NotConferenceOwnerError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc except ConferenceActiveError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="conference_active" ) from exc def _require_utc(value: datetime) -> datetime: """Требовать явную таймзону и привести значение к UTC (в БД и API — только UTC).""" if value.tzinfo is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="datetime_must_be_timezone_aware", ) return value.astimezone(UTC) async def _count_guest_join_miss(ip: str) -> None: """Учесть неудачную попытку гостевого входа в жёстком счётчике. Вынесено отдельно, потому что вызывается из двух веток обработки ошибок (несуществующая конференция и неверный пароль) и обязано бросать 429 ровно так же, как обычный `enforce_rate_limit`. """ await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)