Files
ForbiddenStarsApp/backend/alembic/versions/0014_rating_inputs.py
T
NotBigGhostandClaude Opus 5 8aa6bb7235 Рейтинг: раунд, цели, миры, правило 9 раундов и причина last_standing
Модели и миграция 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
2026-09-14 22:29:01 +03:00

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")