"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями.""" from __future__ import annotations from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_admin from app.core import security from app.core.security import client_ip from app.core.errors import NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User from app.routers.matches import attachment_read, build_match_read from app.schemas import api as s from app.services.match_service import ParticipantInput from app.services import ( achievement_service, admin_service, attachment_service, audit_service, faction_service, match_service, notify, user_service, ) _ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ router = APIRouter(prefix="/admin", tags=["admin"]) # ─── Аутентификация админа ─────────────────────────────────────────────────── @router.post("/auth/login", response_model=s.AdminMe) def admin_login( body: s.AdminLogin, request: Request, response: Response, session: Session = Depends(get_session), ) -> s.AdminMe: admin = admin_service.authenticate_admin(session, body.username, body.password) security.set_admin_session(response, admin.id) # type: ignore[arg-type] audit_service.record( session, actor_id=admin.id, action="login", entity_type="admin", entity_id=admin.id, ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type] @router.post("/auth/logout", response_model=s.OkResponse) def admin_logout(response: Response) -> s.OkResponse: security.clear_admin_session(response) return s.OkResponse() @router.get("/me", response_model=s.AdminMe) def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe: return s.AdminMe(id=admin.id, nickname=admin.nickname, role=admin.role) # type: ignore[arg-type] # ─── Пользователи ──────────────────────────────────────────────────────────── @router.get("/users", response_model=list[s.AdminUserRead]) def list_users( query: str | None = Query(None), session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.AdminUserRead]: return [ s.AdminUserRead( id=u.id, # type: ignore[arg-type] nickname=u.nickname, role=u.role, is_active=u.is_active, auth_provider=u.auth_provider, telegram_id=u.telegram_id, created_at=iso_utc(u.created_at), ) for u in admin_service.list_users(session, query) ] @router.patch("/users/{user_id}", response_model=s.AdminUserRead) def update_user( user_id: int, body: s.AdminUserUpdate, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.AdminUserRead: u = admin_service.update_user(session, user_id, nickname=body.nickname, is_active=body.is_active) audit_service.record( session, actor_id=admin.id, action="update", entity_type="user", entity_id=user_id, payload=body.model_dump(exclude_none=True), ip=client_ip(request), ) session.commit() return s.AdminUserRead( id=u.id, # type: ignore[arg-type] nickname=u.nickname, role=u.role, is_active=u.is_active, auth_provider=u.auth_provider, telegram_id=u.telegram_id, created_at=iso_utc(u.created_at), ) # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в # routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только # отключается (PATCH is_active), удалять нельзя. # ─── Группы ────────────────────────────────────────────────────────────────── @router.get("/groups", response_model=list[s.AdminGroupRead]) def list_groups( session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.AdminGroupRead]: return [ s.AdminGroupRead( id=g.id, name=g.name, owner_id=g.owner_id, created_at=iso_utc(g.created_at) # type: ignore[arg-type] ) for g in admin_service.list_groups(session) ] @router.delete("/groups/{group_id}", response_model=s.OkResponse) def delete_group( group_id: int, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.OkResponse: admin_service.delete_group(session, group_id) audit_service.record( session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id, ip=client_ip(request), ) session.commit() return s.OkResponse() # ─── Партии ────────────────────────────────────────────────────────────────── @router.get("/matches", response_model=list[s.AdminMatchRead]) def list_matches( group_id: int | None = Query(None), user_id: int | None = Query(None), faction_id: int | None = Query(None), limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0), session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.AdminMatchRead]: return [ s.AdminMatchRead( id=m.id, # type: ignore[arg-type] group_id=m.group_id, group_name=gname, status=m.status, played_at=str(m.played_at), duration_minutes=m.duration_minutes, win_reason=m.win_reason, # type: ignore[arg-type] player_count=m.player_count, created_by=m.created_by, created_at=iso_utc(m.created_at), ) for m, gname in admin_service.list_matches( session, limit=limit, offset=offset, group_id=group_id, user_id=user_id, faction_id=faction_id, ) ] @router.get("/matches/{match_id}", response_model=s.MatchRead) def get_match( match_id: int, session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> s.MatchRead: match = match_service.get_match(session, match_id) return build_match_read(session, match, can_modify=True) @router.patch("/matches/{match_id}", response_model=s.MatchRead) def update_match( match_id: int, body: s.MatchUpdate, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.MatchRead: match = match_service.get_match(session, match_id) participants = None if body.participants is not None: participants = [ ParticipantInput( user_id=p.user_id, faction_id=p.faction_id, place=p.place, eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, ) for p in body.participants ] match = match_service.update_match( session, match, played_at=body.played_at, overall_comment=body.overall_comment, overall_comment_set=("overall_comment" in body.model_fields_set), win_reason=body.win_reason, win_reason_set=("win_reason" in body.model_fields_set), participants=participants, expected_version=body.expected_version, ) audit_service.record( session, actor_id=admin.id, action="update", entity_type="match", entity_id=match.id, ip=client_ip(request), ) session.commit() notify.match_changed(session, match) return build_match_read(session, match, can_modify=True) # ─── Фракции (переименование во всей системе) ───────────────────────────────── @router.get("/factions", response_model=list[s.FactionRead]) def list_factions( session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.FactionRead]: return [ s.FactionRead( id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type] ) for f in faction_service.list_factions(session) ] @router.patch("/factions/{faction_id}", response_model=s.FactionRead) def rename_faction( faction_id: int, body: s.FactionRename, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.FactionRead: f = admin_service.rename_faction(session, faction_id, body.name_ru) audit_service.record( session, actor_id=admin.id, action="update", entity_type="faction", entity_id=faction_id, payload={"name_ru": f.name_ru}, ip=client_ip(request), ) session.commit() return s.FactionRead( id=f.id, code=f.code, name_ru=f.name_ru, expansion_id=f.expansion_id # type: ignore[arg-type] ) @router.delete("/matches/{match_id}", response_model=s.OkResponse) def delete_match( match_id: int, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.OkResponse: group_id = match_service.get_match(session, match_id).group_id # для уведомления # До удаления: каскад унесёт участников вместе с партией. participant_ids = notify.match_participant_ids(session, match_id) admin_service.delete_match(session, match_id) audit_service.record( session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, ip=client_ip(request), ) session.commit() notify.match_removed(session, match_id, group_id, participant_ids) return s.OkResponse() # ─── Медиа партии (админ правит в любой момент) ─────────────────────────────── @router.get("/matches/{match_id}/attachments", response_model=list[s.AttachmentRead]) def admin_list_attachments( match_id: int, session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.AttachmentRead]: match_service.get_match(session, match_id) # 404 если партии нет return [ attachment_read(a, f"/api/admin/matches/{match_id}") for a in attachment_service.list_for_match(session, match_id) ] @router.post("/matches/{match_id}/attachments", response_model=s.AttachmentRead) def admin_add_attachment( match_id: int, file: UploadFile = File(...), session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.AttachmentRead: match = match_service.get_match(session, match_id) content, ext = user_service.read_capped_image( file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)." ) att = attachment_service.add_photo( session, match, admin, content, ext, user_service.avatar_media_type(ext) ) notify.match_changed(session, match) return attachment_read(att, f"/api/admin/matches/{match_id}") @router.delete("/matches/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse) def admin_delete_attachment( match_id: int, attachment_id: int, session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> s.OkResponse: match = match_service.get_match(session, match_id) attachment_service.delete(session, match, attachment_id) notify.match_changed(session, match) return s.OkResponse() @router.get("/matches/{match_id}/attachments/{attachment_id}") def admin_get_attachment( match_id: int, attachment_id: int, session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> FileResponse: match_service.get_match(session, match_id) att = attachment_service.get_for_match(session, match_id, attachment_id) path = attachment_service.file_path(att) if not path.exists(): raise NotFoundError("Файл не найден.") return FileResponse( path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"} ) # ─── Ачивки (определения; выдача игрокам — на будущее) ──────────────────────── @router.get("/achievements", response_model=list[s.AchievementRead]) def list_achievements( _admin: User = Depends(get_current_admin), ) -> list[dict]: return achievement_service.list_achievements() @router.post("/achievements", response_model=s.AchievementRead) def create_achievement( body: s.AchievementCreate, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> dict: ach = achievement_service.create(body.name, body.description, body.condition) audit_service.record( session, actor_id=admin.id, action="create", entity_type="achievement", payload={"slug": ach["slug"], "name": ach["name"]}, ip=client_ip(request), ) session.commit() return ach @router.patch("/achievements/{slug}", response_model=s.AchievementRead) def update_achievement( slug: str, body: s.AchievementUpdate, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> dict: ach = achievement_service.update( slug, name=body.name, description=body.description, condition=body.condition ) audit_service.record( session, actor_id=admin.id, action="update", entity_type="achievement", payload={"slug": slug}, ip=client_ip(request), ) session.commit() return ach @router.put("/achievements/{slug}/icon", response_model=s.AchievementRead) def upload_achievement_icon( slug: str, file: UploadFile = File(...), _admin: User = Depends(get_current_admin), ) -> dict: content, ext = user_service.read_capped_image( file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)." ) return achievement_service.set_icon(slug, content, ext) @router.delete("/achievements/{slug}", response_model=s.OkResponse) def delete_achievement( slug: str, request: Request, session: Session = Depends(get_session), admin: User = Depends(get_current_admin), ) -> s.OkResponse: achievement_service.delete(slug) audit_service.record( session, actor_id=admin.id, action="delete", entity_type="achievement", payload={"slug": slug}, ip=client_ip(request), ) session.commit() return s.OkResponse() # ─── Журнал аудита ─────────────────────────────────────────────────────────── @router.get("/audit-logs", response_model=s.AuditLogList) def audit_logs( action: str | None = Query(None), entity_type: str | None = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> dict: return admin_service.list_audit_logs( session, action=action, entity_type=entity_type, limit=limit, offset=offset )