Рейтинг: раунд, цели, миры, правило 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
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""Рейтинг (#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")
|
||||
Reference in New Issue
Block a user