Первоначальная версия VidConf
This commit is contained in:
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
464
backend/api/admin.py
Normal file
464
backend/api/admin.py
Normal 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
188
backend/api/auth.py
Normal 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
152
backend/api/chat.py
Normal 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
255
backend/api/conferences.py
Normal 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
72
backend/api/deps.py
Normal 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
40
backend/api/health.py
Normal 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,
|
||||
}
|
||||
63
backend/api/livekit_webhook.py
Normal file
63
backend/api/livekit_webhook.py
Normal 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
125
backend/api/metrics.py
Normal 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 скрейпит
|
||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||
"""
|
||||
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
31
backend/api/teams.py
Normal 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
145
backend/api/users.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user