Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches,
matches.end_round, match_participants.objectives/worlds. CHECK причины победы
расширяется, только если он в БД есть (старые БД без CHECK таблицу не
пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON,
иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых
партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev
с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают.
API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле
и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем —
в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры
без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds.
PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
131 lines
6.1 KiB
Python
131 lines
6.1 KiB
Python
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
|
|
|
|
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
|
|
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
|
|
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
|
|
last_standing.
|
|
|
|
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
|
|
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
|
|
|
|
Revision ID: 0014_rating_inputs
|
|
Revises: 0013_user_token_version
|
|
Create Date: 2026-09-14
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import inspect
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "0014_rating_inputs"
|
|
down_revision: Union[str, None] = "0013_user_token_version"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
CK_WIN_REASON = "ck_match_win_reason"
|
|
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
|
|
NEW_REASONS = (
|
|
"win_reason IS NULL OR win_reason IN "
|
|
"('objectives','worlds','plastic','resources','last_standing')"
|
|
)
|
|
|
|
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
|
BACKFILL_LAST_STANDING = """
|
|
UPDATE matches SET win_reason = 'last_standing'
|
|
WHERE status = 'finished'
|
|
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
|
AND (SELECT COUNT(*) FROM match_participants mp
|
|
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
|
"""
|
|
|
|
|
|
def _columns(insp, table: str) -> set[str]:
|
|
return {c["name"] for c in insp.get_columns(table)}
|
|
|
|
|
|
def _win_reason_check(insp) -> str | None:
|
|
for ck in insp.get_check_constraints("matches"):
|
|
if ck.get("name") == CK_WIN_REASON:
|
|
return ck["sqltext"]
|
|
return None
|
|
|
|
|
|
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
|
|
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
|
|
|
|
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
|
|
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
|
|
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
|
|
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
|
|
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
|
|
raise RuntimeError(
|
|
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
|
|
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
|
|
)
|
|
with op.batch_alter_table("matches", recreate="always") as b:
|
|
if drop_existing:
|
|
b.drop_constraint(CK_WIN_REASON, type_="check")
|
|
b.create_check_constraint(CK_WIN_REASON, sqltext)
|
|
|
|
|
|
def upgrade() -> None:
|
|
bind = op.get_bind()
|
|
insp = inspect(bind)
|
|
|
|
if "nine_rounds_rule" not in _columns(insp, "groups"):
|
|
with op.batch_alter_table("groups") as b:
|
|
b.add_column(
|
|
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
|
)
|
|
|
|
match_cols = _columns(insp, "matches")
|
|
with op.batch_alter_table("matches") as b:
|
|
if "end_round" not in match_cols:
|
|
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
|
|
if "nine_rounds_rule" not in match_cols:
|
|
b.add_column(
|
|
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
|
)
|
|
|
|
participant_cols = _columns(insp, "match_participants")
|
|
with op.batch_alter_table("match_participants") as b:
|
|
if "objectives" not in participant_cols:
|
|
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
|
|
if "worlds" not in participant_cols:
|
|
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
|
|
|
|
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
|
|
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
|
|
check = _win_reason_check(inspect(bind))
|
|
if check is not None and "last_standing" not in check:
|
|
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
|
|
|
|
op.execute(BACKFILL_LAST_STANDING)
|
|
|
|
|
|
def downgrade() -> None:
|
|
bind = op.get_bind()
|
|
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
|
|
# записывали «по целям»).
|
|
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
|
|
check = _win_reason_check(inspect(bind))
|
|
if check is not None and "last_standing" in check:
|
|
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
|
|
|
|
insp = inspect(bind)
|
|
participant_cols = _columns(insp, "match_participants")
|
|
with op.batch_alter_table("match_participants") as b:
|
|
for name in ("worlds", "objectives"):
|
|
if name in participant_cols:
|
|
b.drop_column(name)
|
|
match_cols = _columns(insp, "matches")
|
|
with op.batch_alter_table("matches") as b:
|
|
for name in ("nine_rounds_rule", "end_round"):
|
|
if name in match_cols:
|
|
b.drop_column(name)
|
|
if "nine_rounds_rule" in _columns(insp, "groups"):
|
|
with op.batch_alter_table("groups") as b:
|
|
b.drop_column("nine_rounds_rule")
|