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