Files
ForbiddenStarsApp/backend/app/routers/matches.py
T
NotBigGhostandClaude Opus 5 5035ee41e6 События: общий рейтинг обновляется у всех, а не только в группе партии
Рейтинг с #80 один на всё приложение: завершённая или удалённая партия двигает
топ, историю и профили всех, кто играл после неё, и страницы других групп.
Событие match по-прежнему идёт группе партии, остальным активным игрокам —
ratings без подробностей о партии. Фронт по обоим сбрасывает все рейтинговые
витрины (invalidateRatingViews); participant_ids больше не нужен. #88

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:29:37 +03:00

370 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
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
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"])
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
"""Участники правки — общее для игроцкого и админского PATCH."""
if body.participants is None:
return None
return [
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,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
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,
objectives=p.objectives,
worlds=p.worlds,
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]
end_round=match.end_round,
nine_rounds_rule=match.nine_rounds_rule,
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
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,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
match = match_service.finish_match(
session,
match,
finish=finish,
win_reason=body.win_reason,
end_round=body.end_round,
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)
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),
end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
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
finished = match.status == "finished" # после удаления статус уже не прочитать
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, finished=finished) # type: ignore[arg-type]
return s.OkResponse()