Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

255
backend/api/conferences.py Normal file
View File

@@ -0,0 +1,255 @@
"""Роутер конференций: создание, «Мои конференции», календарь, резолв, вход, правки (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 enforce_rate_limit
from models.user import User
from schemas.conferences import (
ConferenceCreateIn,
ConferenceOut,
ConferenceUpdateIn,
GuestJoinIn,
JoinIn,
JoinOut,
OccurrenceOut,
ResolveOut,
)
from services.conference_access import (
ConferenceEndedError,
InvalidPasswordError,
PasswordRequiredError,
)
from services.conferences import (
ConferenceActiveError,
ConferenceNotFoundError,
ConferenceService,
InvalidConferenceStateError,
InviteeUserNotFoundError,
NotConferenceOwnerError,
)
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), а признак закрытости неактуален для мёртвой конференции.
"""
await enforce_rate_limit(f"resolve:{_client_ip(request)}")
service = ConferenceService(session)
conference = await service.resolve(q)
if conference is None:
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."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}")
service = ConferenceService(session)
try:
return await service.join_as_guest(conference_id, data=data)
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.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)
def _client_ip(request: Request) -> str:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
return request.client.host if request.client else "unknown"