465 lines
21 KiB
Python
465 lines
21 KiB
Python
"""Роутер администрирования: конференции, пользователи, команды, настройки.
|
||
|
||
Все эндпоинты требуют роль `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,
|
||
)
|