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

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,
)