Партия: общий черновик завершения и запрет результатов до финиша

Результаты (места и причина победы) теперь пишутся только в завершённую партию.
Раньше их можно было проставить идущей: партия оставалась in_progress, висела в
«Незавершённых», в статистику не попадала и очков не приносила — победитель есть,
а игры как бы не было. Дату и общий комментарий по ходу партии править по-прежнему
можно: двойственного состояния они не создают.

Форма завершения получила общий черновик (match_finish_drafts): раскладка мест,
ничьи, выбывшие, комментарии и причина победы видны всем, кто заполняет партию.
Отдельная таблица, а не колонки в matches, намеренно — запись в строку партии
дёргает onupdate у updated_at, то есть версию для оптимистичной блокировки, и
«Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
Черновик удаляется при завершении и уходит каскадом при удалении партии.

Черновик разъезжается отдельным типом SSE-события: он меняется на каждое движение
тайла, и полная инвалидация (лидерборд, история, профили) по нему была бы
расточительной. Автору правки событие не шлётся.

#28

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 19:39:31 +03:00
co-authored by Claude Opus 5
parent 225e62fc1f
commit 8be9a23c0c
7 changed files with 368 additions and 2 deletions
+31
View File
@@ -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: