"""Роутер администрирования: конференции, пользователи, команды, настройки. Все эндпоинты требуют роль `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, Literal 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, TestEmailIn, TestEmailOut, ) 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.email import EmailSendError, create_email_backend from services.instance_settings import ( InstanceSettingsService, InvalidAiLevelError, InvalidContactEmailError, 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)], status: Annotated[Literal["active", "blocked"] | None, Query()] = 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, ) -> AdminUserListOut: """Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без параметра — все) и текстовый поиск по email/имени/названию команды.""" rows, total = await AdminUserRepository(session).list_paginated( status=status, 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, InvalidContactEmailError, ) 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) @router.post("/settings/test-email", response_model=TestEmailOut) async def send_test_email( data: TestEmailIn, admin: Annotated[User, Depends(require_admin)], session: Annotated[AsyncSession, Depends(get_session)], ) -> TestEmailOut: """Отправить тестовое письмо синхронно — проверка почтовой конфигурации без регистрации фиктивного пользователя. Получатель по умолчанию — email текущего администратора. Отправка идёт напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу. Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт, и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`). """ app_settings = get_app_settings() recipient = data.to or admin.email cfg = await InstanceSettingsService(session).get() reply_to = cfg.contact_email if cfg.contact_email_enabled else None is_smtp = app_settings.email_backend == "smtp" smtp_host = app_settings.smtp_host if is_smtp else None smtp_port = app_settings.smtp_port if is_smtp else None backend = create_email_backend(app_settings) try: await backend.send( to=recipient, subject="Тестовое письмо VidConf", body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.", reply_to=reply_to, ) except EmailSendError as exc: return TestEmailOut( success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port ) return TestEmailOut( success=True, message=f"Письмо успешно отправлено на {recipient}", smtp_host=smtp_host, smtp_port=smtp_port, ) 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_domains=cfg.registration_email_domains, contact_email_enabled=cfg.contact_email_enabled, contact_email=cfg.contact_email, ) 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, )