"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление.""" from __future__ import annotations from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user from app.core.security import client_ip from app.core.errors import ConflictError, NoGroupError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import Match, MatchAttachment, User from app.schemas import api as s from app.services import ( attachment_service, audit_service, group_service, match_service, notification_service, notify, user_service, ) from app.services.match_service import FinishInput, ParticipantInput, RosterInput router = APIRouter(prefix="/matches", tags=["matches"]) def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: """AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или '/api/admin/matches/{id}'); cookie игрока/админа доходит до своего пути.""" return s.AttachmentRead( id=att.id, # type: ignore[arg-type] kind=att.kind, url=f"{base}/attachments/{att.id}", mime_type=att.mime_type, size_bytes=att.size_bytes, created_at=iso_utc(att.created_at), ) def _ensure_has_any_group(session: Session, user: User) -> None: if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type] raise NoGroupError() def build_match_read(session: Session, match: Match, *, can_modify: bool = False) -> s.MatchRead: parts = [ s.MatchParticipantRead( user_id=u.id, # type: ignore[arg-type] nickname=u.nickname, faction_id=f.id, # type: ignore[arg-type] faction_name=f.name_ru, place=p.place, eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] ) for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] ] draft_row = match_service.get_finish_draft(session, match.id) # type: ignore[arg-type] draft = None if draft_row is not None: author = session.get(User, draft_row.updated_by) if draft_row.updated_by else None draft = s.MatchFinishDraftRead( data=s.MatchFinishDraftData(**draft_row.data), updated_by=draft_row.updated_by, updated_by_nickname=author.nickname if author else None, updated_at=iso_utc(draft_row.updated_at), ) return s.MatchRead( id=match.id, # type: ignore[arg-type] group_id=match.group_id, status=match.status, played_at=match.played_at, started_at=iso_utc(match.started_at), finished_at=iso_utc(match.finished_at), duration_minutes=match.duration_minutes, win_reason=match.win_reason, # type: ignore[arg-type] player_count=match.player_count, overall_comment=match.overall_comment, created_by=match.created_by, can_modify=can_modify, version=match_service.match_version(match), participants=parts, attachments=[ attachment_read(a, f"/api/matches/{match.id}") for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type] ], finish_draft=draft, ) @router.post("/randomize-faction", response_model=s.RandomizeResponse) def randomize_faction( body: s.RandomizeRequest, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.RandomizeResponse: _ensure_has_any_group(session, user) group_service.assert_member(session, body.group_id, user.id) # type: ignore[arg-type] faction = match_service.randomize_faction(session, body.group_id, body.exclude_faction_ids) return s.RandomizeResponse( faction=s.FactionRead( id=faction.id, code=faction.code, name_ru=faction.name_ru, expansion_id=faction.expansion_id # type: ignore[arg-type] ) ) @router.post("", response_model=s.MatchRead) def start_match( body: s.MatchCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MatchRead: """Этап 1: старт партии (выбор игроков и фракций).""" _ensure_has_any_group(session, user) roster = [ RosterInput(user_id=p.user_id, faction_id=p.faction_id, was_random=p.was_random) for p in body.participants ] match = match_service.create_match(session, user, group_id=body.group_id, roster=roster) audit_service.record( session, actor_id=user.id, action="create", entity_type="match", entity_id=match.id, payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"}, ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() notify.match_changed(session, match) notification_service.match_started(session, match, user.id) # type: ignore[arg-type] return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) @router.post("/{match_id}/finish", response_model=s.MatchRead) def finish_match( match_id: int, body: s.MatchFinish, request: Request, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MatchRead: """Этап 2: завершение партии (места, причина победы, длительность).""" match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) finish = [ FinishInput( user_id=p.user_id, place=p.place, eliminated=p.eliminated, comment=p.comment, faction_id=p.faction_id, ) for p in body.participants ] match = match_service.finish_match( session, match, finish=finish, win_reason=body.win_reason, overall_comment=body.overall_comment, overall_comment_set=("overall_comment" in body.model_fields_set), expected_version=body.expected_version, ) audit_service.record( session, actor_id=user.id, action="update", entity_type="match", entity_id=match.id, payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes}, ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() notify.match_changed(session, match) notification_service.match_finished(session, match, user.id) # type: ignore[arg-type] return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) @router.get("/{match_id}", response_model=s.MatchRead) def get_match( match_id: int, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MatchRead: match = match_service.get_match(session, match_id) group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type] return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) @router.patch("/{match_id}", response_model=s.MatchRead) def update_match( match_id: int, body: s.MatchUpdate, request: Request, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MatchRead: match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) 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=user.id, action="update", entity_type="match", entity_id=match.id, ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() notify.match_changed(session, match) return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) @router.put("/{match_id}/finish-draft", response_model=s.MatchRead) def save_finish_draft( match_id: int, body: s.MatchFinishDraftData, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MatchRead: """Общий черновик формы завершения: то, что видят все, кто заполняет партию. Права те же, что у самой формы. Версию партии запись черновика не двигает — иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.""" match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) match_service.save_finish_draft(session, match, user, body.model_dump()) notify.match_draft_changed(session, match, actor_id=user.id) # type: ignore[arg-type] return build_match_read( session, match, can_modify=match_service.can_modify(session, match, user) ) # ─── Медиа партии (фото) ────────────────────────────────────────────────────── def _assert_can_attach(session: Session, match: Match, user: User) -> None: match_service.assert_can_modify(session, match, user) if match.status != "in_progress": raise ConflictError("Медиа можно прикреплять только до завершения партии.") @router.post("/{match_id}/attachments", response_model=s.AttachmentRead) def add_attachment( match_id: int, file: UploadFile = File(...), session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.AttachmentRead: match = match_service.get_match(session, match_id) _assert_can_attach(session, match, user) content, ext = user_service.read_capped_image( file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)." ) att = attachment_service.add_photo( session, match, user, content, ext, user_service.avatar_media_type(ext) ) notify.match_changed(session, match) return attachment_read(att, f"/api/matches/{match_id}") @router.delete("/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse) def delete_attachment( match_id: int, attachment_id: int, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.OkResponse: match = match_service.get_match(session, match_id) _assert_can_attach(session, match, user) attachment_service.delete(session, match, attachment_id) notify.match_changed(session, match) return s.OkResponse() @router.get("/{match_id}/attachments/{attachment_id}") def get_attachment( match_id: int, attachment_id: int, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> FileResponse: match = match_service.get_match(session, match_id) group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type] 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.delete("/{match_id}", response_model=s.OkResponse) def delete_match( match_id: int, request: Request, expected_version: str | None = Query(None), session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.OkResponse: match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) match_id_val = match.id group_id_val = match.group_id # Участников читаем до удаления: каскад унесёт их строки вместе с партией, # а событию они нужны, чтобы клиент знал, чьи витрины протухли. participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type] match_service.delete_match(session, match, expected_version=expected_version) audit_service.record( session, actor_id=user.id, action="delete", entity_type="match", entity_id=match_id_val, payload={"group_id": group_id_val}, ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type] return s.OkResponse()