Партия: общий черновик завершения и запрет результатов до финиша
Результаты (места и причина победы) теперь пишутся только в завершённую партию. Раньше их можно было проставить идущей: партия оставалась 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:
@@ -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")
|
||||||
@@ -362,6 +362,37 @@ class MatchAttachment(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
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):
|
class Notification(SQLModel, table=True):
|
||||||
|
|||||||
@@ -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]
|
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(
|
return s.MatchRead(
|
||||||
id=match.id, # type: ignore[arg-type]
|
id=match.id, # type: ignore[arg-type]
|
||||||
group_id=match.group_id,
|
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}")
|
attachment_read(a, f"/api/matches/{match.id}")
|
||||||
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
|
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))
|
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:
|
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
|
||||||
|
|||||||
@@ -276,6 +276,25 @@ class AttachmentRead(BaseModel):
|
|||||||
created_at: str
|
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):
|
class MatchRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
group_id: int
|
group_id: int
|
||||||
@@ -292,6 +311,8 @@ class MatchRead(BaseModel):
|
|||||||
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
||||||
participants: list[MatchParticipantRead] = []
|
participants: list[MatchParticipantRead] = []
|
||||||
attachments: list[AttachmentRead] = []
|
attachments: list[AttachmentRead] = []
|
||||||
|
# Общий черновик формы завершения (только у незавершённой партии).
|
||||||
|
finish_draft: MatchFinishDraftRead | None = None
|
||||||
|
|
||||||
|
|
||||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.core.errors import (
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from app.core.timeutil import app_today, iso_utc, utcnow
|
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
|
from app.services import group_service
|
||||||
|
|
||||||
MAX_MATCH_PLAYERS = 6
|
MAX_MATCH_PLAYERS = 6
|
||||||
@@ -286,11 +286,93 @@ def finish_match(
|
|||||||
match.overall_comment = overall_comment or None
|
match.overall_comment = overall_comment or None
|
||||||
|
|
||||||
session.add(match)
|
session.add(match)
|
||||||
|
# Содержимое черновика уже в результатах — второй источник правды не нужен.
|
||||||
|
clear_finish_draft(session, match.id) # type: ignore[arg-type]
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(match)
|
session.refresh(match)
|
||||||
return 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:
|
def can_modify(session: Session, match: Match, user: User) -> bool:
|
||||||
@@ -318,7 +400,18 @@ def update_match(
|
|||||||
participants: list[ParticipantInput] | None = None,
|
participants: list[ParticipantInput] | None = None,
|
||||||
expected_version: str | None = None,
|
expected_version: str | None = None,
|
||||||
) -> Match:
|
) -> 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)
|
assert_version(match, expected_version)
|
||||||
if played_at is not None:
|
if played_at is not None:
|
||||||
match.played_at = played_at
|
match.played_at = played_at
|
||||||
|
|||||||
@@ -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:
|
def match_removed(session: Session, match_id: int, group_id: int) -> None:
|
||||||
"""Партия удалена — уведомить участников группы (обновить списки)."""
|
"""Партия удалена — уведомить участников группы (обновить списки)."""
|
||||||
hub.publish(
|
hub.publish(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user