Первоначальная версия 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

0
backend/api/__init__.py Normal file
View File

464
backend/api/admin.py Normal file
View File

@@ -0,0 +1,464 @@
"""Роутер администрирования: конференции, пользователи, команды, настройки.
Все эндпоинты требуют роль `admin` (`Depends(require_admin)`, 403 иначе).
Правки конференций/удаление переиспользуют `ConferenceService` (тот же
бизнес-слой, что и обычный роутер конференций) — админ проходит проверку
владения как «или владелец, или админ» (см. `ConferenceService._ensure_owner_or_admin`).
Рассылка приглашений и вся отправка писем — только в Celery-задачах; здесь
лишь постановка в очередь и немедленный ответ `202`.
Справочник команд (`teams`) — простой CRUD без бизнес-правил, кроме
уникальности названия; привязка пользователя к команде — `users.team_id`
(`ON DELETE SET NULL`).
"""
import uuid
from pathlib import Path
from typing import Annotated
import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import require_admin
# Алиас обязателен: ниже в этом модуле уже есть роутер-хендлер `get_settings`
# (`GET /admin/settings`) — без переименования он затирает имя импортированной
# функции в globals модуля (последнее связывание имени побеждает).
from core.config import get_settings as get_app_settings
from core.db import get_session
from core.plugins.config import InstanceConfig
from core.security import hash_password
from models.conference import Conference
from models.user import User
from repositories.admin import AdminConferenceRepository, AdminUserRepository, TeamRepository
from repositories.users import UserRepository
from schemas.admin import (
AdminConferenceListOut,
AdminConferenceOut,
AdminUserCreateIn,
AdminUserListOut,
AdminUserOut,
AdminUserUpdateIn,
InvitationsSendIn,
SettingsOut,
TeamCreateIn,
TeamListOut,
TeamOut,
TeamUpdateIn,
)
from schemas.conferences import ConferenceUpdateIn
from services.ai_levels import detect_ai_levels
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.conferences import (
ConferenceActiveError,
ConferenceNotFoundError,
ConferenceService,
InvalidConferenceStateError,
NotConferenceOwnerError,
)
from services.instance_settings import (
InstanceSettingsService,
InvalidAiLevelError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
)
from services.invitations_producer import enqueue_invitations
from services.pipeline_producer import transcription_queue_served
from services.profile import resolve_team_name, set_avatar
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
DEFAULT_LIMIT = 50
MAX_LIMIT = 200
# --- Конференции ------------------------------------------------------------------
@router.get("/conferences", response_model=AdminConferenceListOut)
async def list_conferences(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
status_filter: Annotated[str | None, Query(alias="status")] = None,
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminConferenceListOut:
"""Список всех конференций инстанса с фильтром по статусу и текстовым поиском."""
rows, total = await AdminConferenceRepository(session).list_paginated(
status=status_filter, q=q, limit=limit, offset=offset
)
service = ConferenceService(session)
items = [
_to_admin_conference_out(
service, conference, viewer_id=admin.id, owner_name=owner_name, owner_email=owner_email
)
for conference, owner_name, owner_email in rows
]
return AdminConferenceListOut(items=items, total=total)
@router.patch("/conferences/{conference_id}", response_model=AdminConferenceOut)
async def update_conference(
conference_id: uuid.UUID,
data: ConferenceUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminConferenceOut:
"""Изменить любую конференцию инстанса (реюз `ConferenceService.update`)."""
service = ConferenceService(session)
try:
conference = await service.update(conference_id, actor=admin, 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: # недостижимо для admin, оставлено для полноты
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
owner_name, owner_email = await _load_owner(session, conference)
return _to_admin_conference_out(
service, conference, viewer_id=admin.id, owner_name=owner_name, owner_email=owner_email
)
@router.delete("/conferences/{conference_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conference(
conference_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить любую конференцию инстанса (реюз `ConferenceService.delete`, 409 для активной)."""
service = ConferenceService(session)
try:
await service.delete(conference_id, actor=admin)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except ConferenceActiveError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="conference_active"
) from exc
@router.post(
"/conferences/{conference_id}/invitations",
status_code=status.HTTP_202_ACCEPTED,
)
async def send_conference_invitations(
conference_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
data: InvitationsSendIn = InvitationsSendIn(),
) -> None:
"""Поставить в очередь ручную рассылку .ics-приглашений (отправка — только в Celery)."""
conference = await session.get(Conference, conference_id)
if conference is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found")
enqueue_invitations(conference_id, emails=data.emails)
# --- Пользователи ------------------------------------------------------------------
@router.get("/users", response_model=AdminUserListOut)
async def list_users(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
media_root = _media_root()
items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
for user, team_name in rows
]
return AdminUserListOut(items=items, total=total)
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminUserOut:
"""Создать пользователя от имени администратора.
В отличие от самостоятельной регистрации (`POST /auth/register`),
email сразу считается подтверждённым (`email_verified=True`) — письмо с
подтверждением не отправляется; роль по умолчанию — `user`. Дубль email —
409 `email_already_registered` (тот же код, что у публичной регистрации);
несуществующая команда — 404 `team_not_found`.
"""
repo = UserRepository(session)
if await repo.get_by_email(data.email) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email_already_registered")
if data.team_id is not None:
team = await TeamRepository(session).get(data.team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
user = await repo.create(
email=data.email,
name_user=data.name_user,
password_hash=hash_password(data.password),
team_id=data.team_id,
)
user.email_verified = True
await session.commit()
team_name = await resolve_team_name(session, user.team_id)
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
@router.get("/users/{user_id}", response_model=AdminUserOut)
async def get_user(
user_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminUserOut:
"""Карточка профиля пользователя — те же данные, что в своём профиле."""
row = await AdminUserRepository(session).get_with_team(user_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
user, team_name = row
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
@router.patch("/users/{user_id}", response_model=AdminUserOut)
async def update_user(
user_id: uuid.UUID,
data: AdminUserUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminUserOut:
"""Изменить роль/блокировку/ФИО/команду пользователя.
Запрет самоизменения (409) распространяется только на `role`/`is_blocked` —
своё ФИО/команду админ менять может (та же карточка).
"""
if user_id == admin.id and (data.role is not None or data.is_blocked is not None):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="cannot_modify_self")
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
if data.role is not None:
user.role = data.role
if data.is_blocked is not None:
user.is_blocked = data.is_blocked
if data.name_user is not None:
user.name_user = data.name_user
if "team_id" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — назначить/снять команду; отсутствие
# поля в запросе значение не трогает (тот же паттерн, что
# `summary_recipients` в `services/conferences.py`).
if data.team_id is not None:
team = await TeamRepository(session).get(data.team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
user.team_id = data.team_id
await session.commit()
team_name = await resolve_team_name(session, user.team_id)
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
@router.post("/users/{user_id}/avatar", response_model=AdminUserOut)
async def upload_user_avatar(
user_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> AdminUserOut:
"""Загрузить аватар любому пользователю (та же валидация, что `POST /users/me/avatar`)."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
try:
await set_avatar(_media_root(), user, file)
except AvatarTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large"
) from exc
except AvatarInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type"
) from exc
await session.commit()
team_name = await resolve_team_name(session, user.team_id)
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
# --- Команды ------------------------------------------------------------------------
@router.get("/teams", response_model=TeamListOut)
async def list_teams(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TeamListOut:
"""Список всех команд, отсортированный по названию."""
items, total = await TeamRepository(session).list_all()
return TeamListOut(items=[TeamOut.model_validate(team) for team in items], total=total)
@router.post("/teams", response_model=TeamOut, status_code=status.HTTP_201_CREATED)
async def create_team(
data: TeamCreateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TeamOut:
"""Создать команду; дубль названия (регистрозависимо) — 409."""
repo = TeamRepository(session)
if await repo.get_by_name(data.name) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="team_name_taken")
team = await repo.create(data.name)
await session.commit()
return TeamOut.model_validate(team)
@router.patch("/teams/{team_id}", response_model=TeamOut)
async def update_team(
team_id: uuid.UUID,
data: TeamUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TeamOut:
"""Переименовать команду; нет команды — 404, дубль названия — 409."""
repo = TeamRepository(session)
team = await repo.get(team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
existing = await repo.get_by_name(data.name)
if existing is not None and existing.id != team_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="team_name_taken")
team.name = data.name
await session.commit()
return TeamOut.model_validate(team)
@router.delete("/teams/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_team(
team_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить команду (у пользователей `team_id` обнулится, ON DELETE SET NULL); нет — 404."""
repo = TeamRepository(session)
team = await repo.get(team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
await repo.delete(team)
await session.commit()
# --- Настройки инстанса -------------------------------------------------------------
@router.get("/settings", response_model=SettingsOut)
async def get_settings(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> SettingsOut:
"""Текущие эффективные настройки инстанса.
`transcription_queue_served` вычисляется блокирующим вызовом Celery
(`app.control.inspect`, ждёт ответа брокера/воркеров) — выносится в поток
через `anyio.to_thread.run_sync`, чтобы не блокировать event loop.
"""
cfg = await InstanceSettingsService(session).get()
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served)
@router.put("/settings", response_model=SettingsOut)
async def update_settings(
data: SettingsUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> SettingsOut:
"""Частично обновить настройки инстанса; недоступный уровень AI/таймзона/домен — 400."""
service = InstanceSettingsService(session)
try:
cfg = await service.update(data)
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served)
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut(
chat_enabled=cfg.chat.enabled,
transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg),
transcription_queue_served=transcription_queue_served,
summary_recipients=cfg.summary_recipients,
display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain,
)
def _to_admin_conference_out(
service: ConferenceService,
conference: Conference,
*,
viewer_id: uuid.UUID,
owner_name: str | None,
owner_email: str | None,
) -> AdminConferenceOut:
"""Дополнить `ConferenceOut` данными владельца для админ-таблицы конференций.
`participants` намеренно не заполняется — та же логика, что у `/my`
(список не раздувает состав, ADR-003, п.5); `organizer_name` переиспользует
уже загруженное здесь имя владельца (`owner_name`) — повторного запроса не нужно.
"""
base = service.to_out(conference, viewer_id=viewer_id, organizer_name=owner_name)
return AdminConferenceOut(**base.model_dump(), owner_name=owner_name, owner_email=owner_email)
async def _load_owner(
session: AsyncSession, conference: Conference
) -> tuple[str | None, str | None]:
"""Имя/email владельца конференции (`None`/`None`, если владельца нет — ADR-001)."""
if conference.owner_id is None:
return None, None
owner = await session.get(User, conference.owner_id)
if owner is None:
return None, None
return owner.name_user, owner.email
def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_app_settings().media_root)
def _to_admin_user_out(user: User, *, team_name: str | None, media_root: Path) -> AdminUserOut:
"""Собрать `AdminUserOut` — та же карточка, что и `UserProfileOut`, + модерация."""
return AdminUserOut(
id=user.id,
email=user.email,
name_user=user.name_user,
role=user.role,
is_blocked=user.is_blocked,
email_verified=user.email_verified,
created_at=user.created_at,
team_id=user.team_id,
avatar_url=avatar_url(media_root, user.avatar_path),
team_name=team_name,
)

188
backend/api/auth.py Normal file
View File

@@ -0,0 +1,188 @@
"""Роутер аутентификации: регистрация, подтверждение email, JWT access/refresh, logout."""
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import get_session
from core.redis import redis_client
from models.user import User
from repositories.admin import TeamRepository
from schemas.auth import (
RegisterIn,
RegistrationOptionsOut,
RegistrationTeamOptionOut,
TokenOut,
UserOut,
VerifyEmailIn,
)
from services.auth import (
AuthService,
EmailAlreadyRegisteredError,
EmailNotVerifiedError,
InvalidCredentialsError,
InvalidEmailDomainError,
InvalidRefreshTokenError,
InvalidTeamSelectionError,
InvalidVerificationTokenError,
)
from services.email import create_email_backend
from services.instance_settings import InstanceSettingsService
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
REFRESH_COOKIE_NAME = "refresh_token"
REFRESH_COOKIE_PATH = "/api/v1/auth"
def get_auth_service(session: Annotated[AsyncSession, Depends(get_session)]) -> AuthService:
"""Собрать `AuthService` с реальными зависимостями (БД, Redis, email-бэкенд из настроек)."""
return AuthService(
session=session, redis=redis_client, email_backend=create_email_backend(get_settings())
)
@router.get("/registration-options", response_model=RegistrationOptionsOut)
async def registration_options(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RegistrationOptionsOut:
"""Публичные опции карточки регистрации: выбор команды и верификация домена email.
Список команд отдаётся только при включённой настройке инстанса
`registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
включённой настройке `registration_email_domain`, иначе `None`.
"""
cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
)
@router.post("/register", status_code=status.HTTP_201_CREATED, response_model=UserOut)
async def register(
data: RegisterIn, service: Annotated[AuthService, Depends(get_auth_service)]
) -> User:
"""Зарегистрировать нового пользователя и отправить письмо для подтверждения email."""
try:
return await service.register(
email=data.email,
name_user=data.name_user,
password=data.password,
team_id=data.team_id,
)
except EmailAlreadyRegisteredError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="email_already_registered"
) from exc
except InvalidTeamSelectionError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_team_selection"
) from exc
except InvalidEmailDomainError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
) from exc
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
async def verify_email(
data: VerifyEmailIn, service: Annotated[AuthService, Depends(get_auth_service)]
) -> None:
"""Подтвердить email по токену, полученному в письме."""
try:
await service.verify_email(data.token)
except InvalidVerificationTokenError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_or_expired_token"
) from exc
@router.post("/token", response_model=TokenOut)
async def login(
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
service: Annotated[AuthService, Depends(get_auth_service)],
) -> TokenOut:
"""OAuth2 password flow: вход по email (передаётся как `username`) и паролю."""
try:
pair = await service.login(email=form_data.username, password=form_data.password)
except InvalidCredentialsError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials"
) from exc
except EmailNotVerifiedError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified"
) from exc
_set_refresh_cookie(response, pair.refresh_token)
return TokenOut(access_token=pair.access_token)
@router.post("/refresh", response_model=TokenOut)
async def refresh(
response: Response,
service: Annotated[AuthService, Depends(get_auth_service)],
refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None,
) -> TokenOut:
"""Ротировать refresh-токен из cookie и выдать новый access-токен."""
if refresh_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_refresh_token"
)
try:
pair = await service.refresh(refresh_token)
except InvalidRefreshTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token"
) from exc
_set_refresh_cookie(response, pair.refresh_token)
return TokenOut(access_token=pair.access_token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
response: Response,
service: Annotated[AuthService, Depends(get_auth_service)],
refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None,
) -> None:
"""Отозвать refresh-токен (удалить из Redis) и погасить cookie."""
if refresh_token is not None:
await service.logout(refresh_token)
settings = get_settings()
response.delete_cookie(
REFRESH_COOKIE_NAME,
path=REFRESH_COOKIE_PATH,
secure=settings.auth_cookie_secure,
httponly=True,
samesite="strict",
)
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
"""Установить httpOnly SameSite=Strict cookie с refresh-токеном.
Флаг `Secure` управляется настройкой `auth_cookie_secure` — в dev по
`http://localhost` его нужно отключать (см. `core/config.py`), т.к.
Safari (в отличие от Chrome) не сохраняет Secure-cookie без HTTPS.
"""
settings = get_settings()
response.set_cookie(
key=REFRESH_COOKIE_NAME,
value=refresh_token,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="strict",
path=REFRESH_COOKIE_PATH,
max_age=settings.refresh_token_ttl_days * 24 * 3600,
)

152
backend/api/chat.py Normal file
View File

@@ -0,0 +1,152 @@
"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`.
Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}`
первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в
логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен ->
история последних 50 сообщений открытой сессии -> двунаправленный обмен
`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже).
"""
import asyncio
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import ValidationError
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from core.redis import redis_client
from models.conference import Conference
from schemas.chat import (
ChatAuthIn,
ChatErrorOut,
ChatHistoryOut,
ChatMessageEventOut,
ChatMessageIn,
ChatMessageOut,
)
from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/conferences", tags=["chat"])
# Таймаут ожидания первого (auth) сообщения клиента.
AUTH_TIMEOUT_SECONDS = 10.0
@router.websocket("/{conference_id}/chat")
async def chat_websocket(
websocket: WebSocket,
conference_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном."""
await websocket.accept()
service = ChatService(session)
try:
identity = await _authenticate(websocket, service)
conference = await service.ensure_chat_open(conference_id, identity=identity)
except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code)
return
pubsub = redis_client.pubsub()
channel = chat_channel(conference.id)
# Подписка ДО чтения истории: сообщение,
# опубликованное другим клиентом в окне между SELECT истории и
# subscribe, иначе теряется для подключающегося клиента — Redis начинает
# буферизовать входящие publish для этого соединения сразу после
# subscribe, до первого вызова `get_message`. На стыке возможен дубликат
# (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее
# дедуплицировать по `id`, чем потерять сообщение.
await pubsub.subscribe(channel)
try:
history = await service.history(conference)
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
seen_ids = {item.id for item in history}
async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка.
pass
except* ChatAuthError as eg:
# Допуск был проверен только при коннекте — за время жизни
# долгоживущего WS (LiveKit-токен TTL 6 часов) конференция могла
# завершиться; `persist_and_publish` бросает `ChatUnavailableError`
# при попытке создать сессию пайплайна для уже мёртвой
# конференции — закрываем с тем же кодом, что и при отказе
# на коннекте.
# `except*` всегда связывает `ExceptionGroup` (PEP 654) — на рантайме
# `eg.exceptions[0]` гарантированно `ChatAuthError`; mypy после
# нескольких подряд идущих `except*` моделирует тип `eg` неточно
# (union с "голым" `ChatAuthError`, не имеющим `.exceptions`).
await _close_quietly(websocket, eg.exceptions[0].close_code) # type: ignore[union-attr]
finally:
# Всегда отписываемся и закрываем pubsub-соединение, иначе при частых
# обрывах соединений копятся забытые подписки на стороне Redis.
await pubsub.unsubscribe(channel)
# `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на
# `py.typed` пакета — узкий игнор именно этого вызова.
await pubsub.aclose() # type: ignore[no-untyped-call]
async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdentity:
"""Дождаться первого (auth) сообщения клиента с таймаутом и проверить LiveKit-токен."""
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=AUTH_TIMEOUT_SECONDS)
except (TimeoutError, WebSocketDisconnect) as exc:
raise InvalidTokenError from exc
try:
envelope = ChatAuthIn.model_validate_json(raw)
except ValidationError as exc:
raise InvalidTokenError from exc
return await service.authenticate(envelope.token)
async def _pump_pubsub_to_websocket(
websocket: WebSocket, pubsub: PubSub, seen_ids: set[int]
) -> None:
"""Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту.
`seen_ids` — id сообщений, уже отправленных клиенту в `history` (на
стыке подписки и SELECT истории возможен дубликат, см. докстринг
`chat_websocket`) — такие сообщения не пересылаются повторно.
"""
while True:
raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
if raw is None:
continue
message = ChatMessageOut.model_validate_json(raw["data"])
if message.id in seen_ids:
continue
seen_ids.add(message.id)
await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json"))
async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
) -> None:
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их."""
while True:
raw = await websocket.receive_text()
try:
envelope = ChatMessageIn.model_validate_json(raw)
except ValidationError:
await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json"))
continue
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
async def _close_quietly(websocket: WebSocket, code: int) -> None:
"""Закрыть WS с заданным кодом, не роняя обработчик, если клиент уже отвалился."""
try:
await websocket.close(code=code)
except Exception: # noqa: BLE001 — соединение уже могло быть разорвано клиентом
logger.debug("chat websocket: close(%s) на уже разорванном соединении", code)

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"

72
backend/api/deps.py Normal file
View File

@@ -0,0 +1,72 @@
"""Зависимости FastAPI для аутентификации (RBAC): user / guest / admin."""
import uuid
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from core.security import decode_token
from models.user import User
from repositories.users import UserRepository
# `auto_error=False`, чтобы отсутствие заголовка не приводило к автоматической
# ошибке — guest (отсутствие JWT) обрабатывается явно в get_current_user_optional.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=False)
async def get_current_user(
token: Annotated[str | None, Depends(oauth2_scheme)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User:
"""Вернуть текущего пользователя по access-токену; 401 если не аутентифицирован."""
user = await _user_from_token(token, session)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not_authenticated")
return user
async def get_current_user_optional(
token: Annotated[str | None, Depends(oauth2_scheme)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User | None:
"""Вернуть текущего пользователя либо `None` для guest (без ошибки).
Роль guest в системе — это отсутствие JWT, а не отдельное enum-значение в БД.
"""
return await _user_from_token(token, session)
async def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
"""Требовать роль `admin`; иначе 403."""
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_required")
return user
async def _user_from_token(token: str | None, session: AsyncSession) -> User | None:
"""Общая логика резолва пользователя из access-токена (или None при любой проблеме).
Заблокированный администратором пользователь (`is_blocked`) трактуется
так же, как отсутствие пользователя — блокировка действует немедленно,
не дожидаясь истечения уже выданного access-токена.
"""
if token is None:
return None
try:
payload = decode_token(token)
except jwt.PyJWTError:
return None
if payload.get("type") != "access":
return None
try:
user_id = uuid.UUID(str(payload.get("sub")))
except (ValueError, TypeError):
return None
user = await UserRepository(session).get_by_id(user_id)
if user is not None and user.is_blocked:
return None
return user

40
backend/api/health.py Normal file
View File

@@ -0,0 +1,40 @@
"""Endpoint для проверки здоровья."""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import get_session
from core.redis import redis_client
router = APIRouter()
@router.get("/api/health")
async def health(session: AsyncSession = Depends(get_session)) -> dict[str, bool | str]:
"""Отчет о статусе приложения и связи с БД/Redis.
Поле `version` — версия инстанса (`VIDCONF_VERSION` из `.env`,
пишет `install.sh` из корневого файла `VERSION`); футер админки
берёт его отсюда, а не из версии сборки фронтенда.
"""
db_ok = False
try:
await session.execute(text("SELECT 1"))
db_ok = True
except Exception: # noqa: BLE001
db_ok = False
redis_ok = False
try:
redis_ok = bool(await redis_client.ping())
except Exception: # noqa: BLE001
redis_ok = False
return {
"status": "ok",
"db": db_ok,
"redis": redis_ok,
"version": get_settings().vidconf_version,
}

View File

@@ -0,0 +1,63 @@
"""Приёмник webhook-событий LiveKit (без JWT — верификация подписью LiveKit)."""
import logging
from typing import Annotated, Any, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from livekit import api
from sqlalchemy import CursorResult
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import get_session
from models.webhook_event import LivekitWebhookEvent
from services.webhook_handlers import WebhookDispatcher
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"])
@router.post("/webhook")
async def receive_webhook(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, str]:
"""Принять, верифицировать и обработать webhook-событие LiveKit.
Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в
`livekit_webhook_events` в одной транзакции с эффектами обработчика —
при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200.
"""
settings = get_settings()
raw_body = await request.body()
receiver = api.WebhookReceiver(
api.TokenVerifier(settings.livekit_api_key, settings.livekit_api_secret)
)
try:
event = receiver.receive(raw_body.decode(), authorization or "")
except Exception as exc: # noqa: BLE001 — SDK кидает generic Exception на невалидную подпись
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_signature"
) from exc
insert_result = cast(
CursorResult[Any],
await session.execute(
pg_insert(LivekitWebhookEvent)
.values(event_id=event.id, event_type=event.event)
.on_conflict_do_nothing(index_elements=["event_id"])
),
)
if insert_result.rowcount == 0:
# Дубль уже обработанного события — пропускаем эффекты, но отвечаем 200.
await session.commit()
return {"status": "duplicate"}
dispatcher = WebhookDispatcher(session)
await dispatcher.dispatch(event)
await session.commit()
return {"status": "ok"}

125
backend/api/metrics.py Normal file
View File

@@ -0,0 +1,125 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней
сети, а не через публичный `/api/`-гейтвей.
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно
НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике
запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их
асинхронные источники (БД, Redis) можно опросить обычным `await` вместо
реализации синхронного `prometheus_client.registry.Collector`.
"""
import time
from collections.abc import Awaitable, Callable
from fastapi import APIRouter, Depends, Request, Response
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_latest
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match
from core.db import get_session
from core.redis import redis_client
from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository
router = APIRouter()
# --- Латентность HTTP-запросов по маршрутам --------------------------------
HTTP_REQUEST_DURATION_SECONDS = Histogram(
"vidconf_http_request_duration_seconds",
"Латентность HTTP-запросов backend по маршрутам",
labelnames=("method", "path", "status"),
)
async def prometheus_latency_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Замерить латентность запроса и записать в `HTTP_REQUEST_DURATION_SECONDS`.
Метка `path` — шаблон маршрута (`/api/v1/conferences/{conference_id}`), а
не сырой URL: иначе каждый UUID/slug в пути породил бы собственную серию
меток (неограниченная кардинальность). Шаблон резолвится постфактум
поиском совпавшего маршрута среди `request.app.routes` (FastAPI/Starlette
не кладёт его в `request.scope` до входа в сам эндпоинт, а `call_next`
оборачивает вызов целиком) — тот же приём, что использует
`starlette.routing.Router` внутри себя для диспетчеризации.
"""
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
path_template = _match_route_path(request)
HTTP_REQUEST_DURATION_SECONDS.labels(
method=request.method, path=path_template, status=str(response.status_code)
).observe(duration)
return response
def _match_route_path(request: Request) -> str:
"""Найти шаблон пути совпавшего маршрута; сырой `request.url.path`, если не найден (404)."""
for route in request.app.routes:
match, _ = route.matches(request.scope)
if match == Match.FULL:
return getattr(route, "path", request.url.path)
return request.url.path
# --- Gauge числа сеансов по статусу пайплайна -------------------------------
PIPELINE_SESSIONS = Gauge(
"vidconf_pipeline_sessions",
"Число сеансов конференций в каждом статусе пайплайна пост-обработки",
labelnames=("status",),
)
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`."""
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
for status in PIPELINE_STATUSES:
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
# --- Gauge глубины очередей Celery (Redis) ----------------------------------
CELERY_QUEUES = ("transcription", "summarize", "notify", "celery")
"""Очереди, за которыми следим (`workers/celery_app.py::app.conf.task_routes`,
`docs/deploy/scaling.md`): выделенные `transcription`/`summarize`/`notify` +
дефолтная `celery` (обслуживающие задачи без явного маршрута)."""
CELERY_QUEUE_DEPTH = Gauge(
"vidconf_celery_queue_depth",
"Число задач, ожидающих обработки в очереди Celery (redis LLEN)",
labelnames=("queue",),
)
async def _refresh_celery_queue_depth_gauge() -> None:
"""Пересчитать `vidconf_celery_queue_depth` по всем отслеживаемым очередям.
Список Redis, лежащий за очередью Celery, называется так же, как сама
очередь (транспорт `kombu` с брокером `redis` кладёт задачи в список по
имени очереди) — `LLEN` даёт точную глубину backlog'а на момент scrape.
"""
for queue in CELERY_QUEUES:
depth = await redis_client.llen(queue)
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
@router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition.
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
коллектором) — значение в ответе всегда актуально на момент scrape,
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
"""
await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)

31
backend/api/teams.py Normal file
View File

@@ -0,0 +1,31 @@
"""Роутер справочника команд для аутентифицированных пользователей.
Отдельно от `/admin/teams` (админ-only CRUD): здесь только чтение полного
списка — нужно странице профиля (выбор команды). В отличие от
`GET /auth/registration-options`, список НЕ гасится тумблером
`registration_team_choice` (та настройка — только про публичную форму
регистрации, не про профиль уже аутентифицированного пользователя).
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.db import get_session
from models.user import User
from repositories.admin import TeamRepository
from schemas.admin import TeamOut
router = APIRouter(prefix="/api/v1/teams", tags=["teams"])
@router.get("", response_model=list[TeamOut])
async def list_teams(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> list[TeamOut]:
"""Полный справочник команд, отсортированный по названию (выбор команды в профиле)."""
items, _ = await TeamRepository(session).list_all()
return [TeamOut.model_validate(team) for team in items]

145
backend/api/users.py Normal file
View File

@@ -0,0 +1,145 @@
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.config import get_settings
from core.db import get_session
from core.security import hash_password, verify_password
from models.user import User
from repositories.users import UserRepository
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.profile import (
TeamNotFoundError,
clear_avatar,
resolve_team_name,
update_profile_fields,
)
from services.profile import set_avatar as _set_avatar
router = APIRouter(prefix="/api/v1/users", tags=["users"])
# Число совпадений, возвращаемых поиском по `q` (пикер участников).
SEARCH_LIMIT = 20
@router.get("/me", response_model=UserProfileOut)
async def read_current_user(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserProfileOut:
"""Вернуть профиль текущего аутентифицированного пользователя."""
return await _to_profile_out(session, user)
@router.patch("/me", response_model=UserProfileOut)
async def update_current_user(
data: ProfileUpdateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserProfileOut:
"""Изменить ФИО и/или команду текущего пользователя; email — read-only."""
try:
await update_profile_fields(
session,
user,
name_user=data.name_user,
team_id=data.team_id,
team_id_is_set="team_id" in data.model_fields_set,
)
except TeamNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") from exc
await session.commit()
return await _to_profile_out(session, user)
@router.post("/me/avatar", response_model=UserProfileOut)
async def upload_current_user_avatar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> UserProfileOut:
"""Загрузить аватар текущего пользователя (jpeg/png/webp, до 2 МБ)."""
try:
await _set_avatar(_media_root(), user, file)
except AvatarTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large"
) from exc
except AvatarInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type"
) from exc
await session.commit()
return await _to_profile_out(session, user)
@router.delete("/me/avatar", status_code=status.HTTP_204_NO_CONTENT)
async def delete_current_user_avatar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить аватар текущего пользователя."""
clear_avatar(_media_root(), user)
await session.commit()
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_current_user_password(
data: PasswordChangeIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Сменить пароль текущего пользователя.
Refresh-сессии сознательно НЕ отзываются — отзыв всех сессий появится
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
`docs/architecture/adr/005-password-reset-deferred.md`).
"""
if not verify_password(data.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
)
user.password_hash = hash_password(data.new_password)
await session.commit()
@router.get("", response_model=list[UserListItemOut])
async def list_users(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str | None, Query()] = None,
) -> list[UserListItemOut]:
"""Пикер участников конференции: без `q` — полный список; с `q` — поиск имя/email."""
media_root = _media_root()
users = await UserRepository(session).search(q=q, limit=SEARCH_LIMIT)
return [
UserListItemOut(
id=u.id, display_name=u.name_user, avatar_url=avatar_url(media_root, u.avatar_path)
)
for u in users
]
def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_settings().media_root)
async def _to_profile_out(session: AsyncSession, user: User) -> UserProfileOut:
"""Собрать `UserProfileOut` — общая сборка для своего профиля и карточки в админке."""
team_name = await resolve_team_name(session, user.team_id)
return UserProfileOut(
id=user.id,
email=user.email,
name_user=user.name_user,
role=user.role,
avatar_url=avatar_url(_media_root(), user.avatar_path),
team_id=user.team_id,
team_name=team_name,
)