diff --git a/backend/alembic/versions/0012_match_finish_draft.py b/backend/alembic/versions/0012_match_finish_draft.py new file mode 100644 index 0000000..930c6ed --- /dev/null +++ b/backend/alembic/versions/0012_match_finish_draft.py @@ -0,0 +1,51 @@ +"""Партия: общий черновик формы завершения (совместное заполнение результатов). + +Идемпотентна: на свежей БД таблицу создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД создаёт таблицу. + +Revision ID: 0012_match_finish_draft +Revises: 0011_user_history_prefs +Create Date: 2026-09-09 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0012_match_finish_draft" +down_revision: Union[str, None] = "0011_user_history_prefs" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "match_finish_drafts" in insp.get_table_names(): + return + op.create_table( + "match_finish_drafts", + sa.Column( + "match_id", + sa.Integer(), + sa.ForeignKey("matches.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("data", sa.JSON(), nullable=False), + sa.Column( + "updated_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "match_finish_drafts" in insp.get_table_names(): + op.drop_table("match_finish_drafts") diff --git a/backend/app/models.py b/backend/app/models.py index d206e19..993d68e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -362,6 +362,37 @@ class MatchAttachment(SQLModel, table=True): created_at: datetime = Field(default_factory=utcnow, nullable=False) + +class MatchFinishDraft(SQLModel, table=True): + """Общий черновик формы завершения партии: раскладка мест, ничьи, выбывшие, + комментарии и причина победы, пока партию не завершили. + + Отдельная таблица, а не колонки в matches, намеренно: запись в строку партии + дёргает onupdate у matches.updated_at, а это версия для оптимистичной блокировки — + «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку. + Живёт только пока партия идёт: finish_match удаляет строку, удаление партии + уносит её каскадом.""" + + __tablename__ = "match_finish_drafts" + + match_id: int | None = Field( + default=None, + sa_column=Column( + Integer, ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True + ), + ) + data: dict = Field(sa_column=Column(JSON, nullable=False)) + updated_by: int | None = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + ) + updated_at: datetime = Field( + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, + nullable=False, + ) + # ─── Уведомления ───────────────────────────────────────────────────────────── class Notification(SQLModel, table=True): diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index e5d3b4c..eb16f01 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -60,6 +60,16 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False ) 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, @@ -79,6 +89,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False 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, ) @@ -233,6 +244,26 @@ def update_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: diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index aedfa14..4a3288e 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -276,6 +276,25 @@ class AttachmentRead(BaseModel): created_at: str +class MatchFinishDraftData(BaseModel): + """Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие, + комментарии об игроках и причина победы. Промежуточное состояние, поэтому + места не валидируются — человек раскладывает их постепенно.""" + + blocks: list[list[int]] = [] + eliminated: list[int] = [] + comments: dict[str, str] = {} + win_reason: WinReason | None = None + overall_comment: str | None = None + + +class MatchFinishDraftRead(BaseModel): + data: MatchFinishDraftData + updated_by: int | None = None + updated_by_nickname: str | None = None + updated_at: str + + class MatchRead(BaseModel): id: int group_id: int @@ -292,6 +311,8 @@ class MatchRead(BaseModel): version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно participants: list[MatchParticipantRead] = [] attachments: list[AttachmentRead] = [] + # Общий черновик формы завершения (только у незавершённой партии). + finish_draft: MatchFinishDraftRead | None = None # ─── Статистика ────────────────────────────────────────────────────────────── diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 6639686..aecb9ef 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -17,7 +17,7 @@ from app.core.errors import ( ValidationError, ) from app.core.timeutil import app_today, iso_utc, utcnow -from app.models import Faction, GroupMember, Match, MatchParticipant, User +from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.services import group_service MAX_MATCH_PLAYERS = 6 @@ -286,11 +286,93 @@ def finish_match( match.overall_comment = overall_comment or None session.add(match) + # Содержимое черновика уже в результатах — второй источник правды не нужен. + clear_finish_draft(session, match.id) # type: ignore[arg-type] session.commit() session.refresh(match) return match + +# ─── Черновик завершения (совместное заполнение формы) ──────────────────────── + +def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None: + return session.get(MatchFinishDraft, match_id) + + +def _validate_draft(session: Session, match: Match, data: dict) -> dict: + """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками + партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек + раскладывает их постепенно, и промежуточное состояние может быть любым.""" + if not isinstance(data, dict): + raise ValidationError("Некорректный черновик.") + blocks = data.get("blocks") or [] + eliminated = data.get("eliminated") or [] + comments = data.get("comments") or {} + win_reason = data.get("win_reason") + if not isinstance(blocks, list) or not isinstance(eliminated, list): + raise ValidationError("Некорректный черновик.") + if not isinstance(comments, dict): + raise ValidationError("Некорректный черновик.") + if win_reason is not None and win_reason not in WIN_REASONS: + raise ValidationError("Некорректная причина победы.") + + participant_ids = { + p.user_id + for p in session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + } + listed: list[int] = [] + for block in blocks: + if not isinstance(block, list): + raise ValidationError("Некорректный черновик.") + listed.extend(block) + listed.extend(eliminated) + if any(not isinstance(uid, int) for uid in listed): + raise ValidationError("Некорректный черновик.") + if set(listed) - participant_ids: + raise ValidationError("В черновике есть игроки не из этой партии.") + overall = data.get("overall_comment") + if overall is not None and not isinstance(overall, str): + raise ValidationError("Некорректный черновик.") + return { + "blocks": blocks, + "eliminated": eliminated, + "comments": {str(k): str(v) for k, v in comments.items()}, + "win_reason": win_reason, + "overall_comment": overall, + } + + +def save_finish_draft( + session: Session, match: Match, user: User, data: dict +) -> MatchFinishDraft: + """Сохранить общий черновик формы завершения (последняя запись побеждает). + + Версию партии (updated_at) намеренно НЕ двигаем: иначе «Завершить» у второго + участника ловил бы STALE_WRITE на каждую чужую правку черновика.""" + if match.status != "in_progress": + raise ConflictError("Партия уже завершена.") + payload = _validate_draft(session, match, data) + draft = session.get(MatchFinishDraft, match.id) + if draft is None: + draft = MatchFinishDraft(match_id=match.id, data=payload, updated_by=user.id) + else: + draft.data = payload + draft.updated_by = user.id + draft.updated_at = utcnow() + session.add(draft) + session.commit() + session.refresh(draft) + return draft + + +def clear_finish_draft(session: Session, match_id: int) -> None: + draft = session.get(MatchFinishDraft, match_id) + if draft is not None: + session.delete(draft) + # ─── Права / правка / удаление ──────────────────────────────────────────────── def can_modify(session: Session, match: Match, user: User) -> bool: @@ -318,7 +400,18 @@ def update_match( participants: list[ParticipantInput] | None = None, expected_version: str | None = None, ) -> Match: - """Правка завершённой партии (админ): полный список участников с местами.""" + """Правка партии: состав с местами, дата, комментарий, причина победы. + + Результаты (места и причина победы) пишутся только в завершённую партию: иначе они + оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не + попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). + Дату и общий комментарий править можно и по ходу партии — двойственного состояния + они не создают.""" + results_touched = participants is not None or win_reason_set + if results_touched and match.status != "finished": + raise ConflictError( + "Результаты незавершённой партии нельзя править — сначала завершите её." + ) assert_version(match, expected_version) if played_at is not None: match.played_at = played_at diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index d2bd665..b31482a 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -25,6 +25,16 @@ def match_changed(session: Session, match: Match) -> None: ) +def match_draft_changed(session: Session, match: Match, actor_id: int) -> None: + """Черновик формы завершения изменился — остальным заполняющим из группы. + + Отдельный тип события: черновик меняется на каждое движение тайла, и гнать по нему + полную инвалидацию (лидерборд, история, профили) было бы расточительно. Автору + правки событие не шлём — у него уже актуальное состояние.""" + ids = [uid for uid in _group_member_ids(session, match.group_id) if uid != actor_id] + hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id}) + + def match_removed(session: Session, match_id: int, group_id: int) -> None: """Партия удалена — уведомить участников группы (обновить списки).""" hub.publish( diff --git a/backend/tests/test_finish_draft.py b/backend/tests/test_finish_draft.py new file mode 100644 index 0000000..e63e881 --- /dev/null +++ b/backend/tests/test_finish_draft.py @@ -0,0 +1,129 @@ +"""Черновик формы завершения: совместное заполнение результатов партии. + +Плюс запрет правки незавершённой партии (места без завершения — «результат есть, +а игры как бы не было»).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _start(client: TestClient, engine) -> tuple[dict, int, int, int]: + me = login(client, "Хост") + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Игрок2") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + started = start_match( + client, gid, + [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], + ) + assert started.status_code == 200, started.text + return me, gid, p2, started.json()["id"] + + +def _draft(client: TestClient, mid: int, body: dict): + return client.put(f"/api/matches/{mid}/finish-draft", json=body, headers=csrf_headers(client)) + + +def test_draft_is_shared_between_participants(client: TestClient, engine): + """Второй участник видит раскладку первого, не перезагружая страницу.""" + me, gid, p2, mid = _start(client, engine) + + r = _draft(client, mid, { + "blocks": [[me["id"]], [p2]], + "eliminated": [], + "comments": {str(p2): "почти успел"}, + "win_reason": "worlds", + }) + assert r.status_code == 200, r.text + assert r.json()["finish_draft"]["data"]["blocks"] == [[me["id"]], [p2]] + + login(client, "Игрок2") + seen = client.get(f"/api/matches/{mid}").json()["finish_draft"] + assert seen["data"]["blocks"] == [[me["id"]], [p2]] + assert seen["data"]["comments"][str(p2)] == "почти успел" + assert seen["data"]["win_reason"] == "worlds" + assert seen["updated_by_nickname"] == "Хост" + + +def test_draft_does_not_move_match_version(client: TestClient, engine): + """Черновик не трогает версию партии. + + Иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую + правку — ровно то, ради чего черновик и делался.""" + me, gid, p2, mid = _start(client, engine) + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + assert _draft(client, mid, {"blocks": [[p2], [me["id"]]]}).status_code == 200 + assert client.get(f"/api/matches/{mid}").json()["version"] == v1 + + # И завершение со «старой» (на деле актуальной) версией проходит. + fin = finish_match( + client, mid, [{"user_id": p2, "place": 1}, {"user_id": me["id"], "place": 2}], + ) + assert fin.status_code == 200, fin.text + + +def test_draft_cleared_after_finish(client: TestClient, engine): + me, gid, p2, mid = _start(client, engine) + assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 200 + + finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]) + assert client.get(f"/api/matches/{mid}").json()["finish_draft"] is None + + # В завершённую партию черновик не пишется. + assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 409 + + +def test_draft_rejects_outsider_and_foreign_players(client: TestClient, engine): + me, gid, p2, mid = _start(client, engine) + stranger = login(client, "Чужак") # в группе не состоит + assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 403 + + login(client, "Хост") + bad = _draft(client, mid, {"blocks": [[me["id"]], [stranger["id"]]]}) + assert bad.status_code == 422, bad.text + + +def test_in_progress_match_cannot_be_patched(client: TestClient, engine): + """Места и причина победы у идущей партии — только через завершение. + + Иначе партия остаётся in_progress с проставленными местами: висит в + «Незавершённых», но в статистику не попадает и очков не приносит.""" + me, gid, p2, mid = _start(client, engine) + parts = client.get(f"/api/matches/{mid}").json()["participants"] + body = { + "participants": [ + {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1} + for i, p in enumerate(parts) + ], + "win_reason": "objectives", + } + r = client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client)) + assert r.status_code == 409, r.text + assert client.get(f"/api/matches/{mid}").json()["status"] == "in_progress" + + +def test_admin_cannot_patch_in_progress_match(client: TestClient, engine, make_admin): + me, gid, p2, mid = _start(client, engine) + parts = client.get(f"/api/matches/{mid}").json()["participants"] + + make_admin("admin", "secret123") + assert client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ).status_code == 200 + body = { + "participants": [ + {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1} + for i, p in enumerate(parts) + ], + "win_reason": "objectives", + } + r = client.patch(f"/api/admin/matches/{mid}", json=body, headers=csrf_headers(client)) + assert r.status_code == 409, r.text