Files
vidconf/backend/api/admin.py

465 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роутер администрирования: конференции, пользователи, команды, настройки.
Все эндпоинты требуют роль `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,
)