Files
ForbiddenStarsApp/backend/app/models.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

474 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQLModel-модели (слой БД). Все таблицы v1 + заготовка под вложения.
Имена столбцов/таблиц — английские; отображаемые имена справочников (RU) — в `name_ru`.
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
Column,
Date,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlmodel import Field, SQLModel
from app.core.timeutil import utcnow
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
class Expansion(SQLModel, table=True):
__tablename__ = "expansions"
id: int | None = Field(default=None, primary_key=True)
code: str = Field(sa_column=Column(String(32), nullable=False, unique=True))
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class Faction(SQLModel, table=True):
__tablename__ = "factions"
id: int | None = Field(default=None, primary_key=True)
code: str = Field(sa_column=Column(String(32), nullable=False, unique=True))
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
expansion_id: int = Field(
sa_column=Column(
Integer,
ForeignKey("expansions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
)
sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Пользователи и идентичности ─────────────────────────────────────────────
class User(SQLModel, table=True):
__tablename__ = "users"
__table_args__ = (
CheckConstraint("role IN ('player','admin')", name="ck_users_role"),
CheckConstraint(
"auth_provider IN ('stub','telegram','local')",
name="ck_users_auth_provider",
),
CheckConstraint(
"role <> 'admin' OR password_hash IS NOT NULL",
name="ck_users_admin_has_password",
),
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
CheckConstraint(
"history_detail IN ('compact','full')", name="ck_users_history_detail"
),
)
id: int | None = Field(default=None, primary_key=True)
telegram_id: int | None = Field(
sa_column=Column(BigInteger, nullable=True, unique=True)
)
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
# Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар.
avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
auth_provider: str = Field(
default="stub",
sa_column=Column(String(16), nullable=False, server_default="stub"),
)
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
token_version: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
active_group_id: int | None = Field(
sa_column=Column(
Integer,
ForeignKey("groups.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
)
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
history_mode: str = Field(
default="all",
sa_column=Column(String(8), nullable=False, server_default="all"),
)
history_detail: str = Field(
default="compact",
sa_column=Column(String(8), nullable=False, server_default="compact"),
)
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
favorite_faction_id: int | None = Field(
default=None,
sa_column=Column(
Integer,
ForeignKey("factions.id", ondelete="SET NULL"),
nullable=True,
),
)
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
class UserAchievement(SQLModel, table=True):
"""Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется.
Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД)."""
__tablename__ = "user_achievements"
__table_args__ = (
UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True):
__tablename__ = "auth_identity"
__table_args__ = (
UniqueConstraint("provider", "external_id", name="uq_identity_provider_external"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
provider: str = Field(sa_column=Column(String(16), nullable=False))
external_id: str = Field(sa_column=Column(String(64), nullable=False))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Группы и членство ───────────────────────────────────────────────────────
class Group(SQLModel, table=True):
__tablename__ = "groups"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(sa_column=Column(String(64), nullable=False))
owner_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
)
)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
class GroupMember(SQLModel, table=True):
__tablename__ = "group_members"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
CheckConstraint("role IN ('owner','member')", name="ck_member_role"),
)
id: int | None = Field(default=None, primary_key=True)
group_id: int = Field(
sa_column=Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
joined_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupInvitation(SQLModel, table=True):
"""Ожидающее приглашение в группу. Хранятся только pending: при ответе строка
удаляется (accept → создаётся GroupMember; decline → просто удаление)."""
__tablename__ = "group_invitations"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
)
id: int | None = Field(default=None, primary_key=True)
group_id: int = Field(
sa_column=Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
invited_by_id: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupExpansion(SQLModel, table=True):
__tablename__ = "group_expansions"
__table_args__ = (
UniqueConstraint("group_id", "expansion_id", name="uq_group_expansion"),
)
id: int | None = Field(default=None, primary_key=True)
group_id: int = Field(
sa_column=Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
)
expansion_id: int = Field(
sa_column=Column(
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
)
)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Партии и участники ──────────────────────────────────────────────────────
class Match(SQLModel, table=True):
__tablename__ = "matches"
__table_args__ = (
Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint(
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')",
name="ck_match_win_reason",
),
)
id: int | None = Field(default=None, primary_key=True)
group_id: int = Field(
sa_column=Column(
Integer, ForeignKey("groups.id", ondelete="RESTRICT"), nullable=False
)
)
# Python-default 'in_progress' (новые партии стартуют незавершёнными). Серверного
# default нет: статус всегда задаётся ORM явно. Бэкфилл старых строк ('finished')
# делает миграция 0003 при ADD COLUMN на существующей БД.
status: str = Field(default="in_progress", sa_column=Column(String(16), nullable=False))
played_at: date = Field(sa_column=Column(Date, nullable=False))
started_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Снимок Group.nine_rounds_rule на момент старта партии.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
)
)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
class MatchParticipant(SQLModel, table=True):
__tablename__ = "match_participants"
__table_args__ = (
# Без UNIQUE(match_id, place) — ничьи разрешены (competition ranking 1,2,2,4).
UniqueConstraint("match_id", "user_id", name="uq_participant_user"),
UniqueConstraint("match_id", "faction_id", name="uq_participant_faction"),
CheckConstraint("place >= 1", name="ck_participant_place"),
)
id: int | None = Field(default=None, primary_key=True)
match_id: int = Field(
sa_column=Column(
Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True
)
)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
)
)
faction_id: int = Field(
sa_column=Column(
Integer, ForeignKey("factions.id", ondelete="RESTRICT"), nullable=False, index=True
)
)
place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Выбыл из партии: place авто = последнее место (отображается красным крестиком).
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True))
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
# на конец партии. У выбывшего миров 0.
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class MatchAttachment(SQLModel, table=True):
"""Медиа партии (фото в общем комментарии). Файл — на томе uploads, в БД метаданные.
kind пока всегда 'photo' (задел под видео). storage_path — относительный путь под
settings.upload_dir."""
__tablename__ = "match_attachments"
id: int | None = Field(default=None, primary_key=True)
match_id: int = Field(
sa_column=Column(
Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True
)
)
uploaded_by: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
kind: str = Field(default="photo", sa_column=Column(String(16), nullable=False, server_default="photo"))
storage_path: str = Field(sa_column=Column(String(255), nullable=False))
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
size_bytes: int = Field(sa_column=Column(Integer, 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):
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя),
прочитанные чистятся спустя 72 часа — фоном и при чтении списка."""
__tablename__ = "notifications"
__table_args__ = (
Index("ix_notifications_user_created", "user_id", "created_at"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
type: str = Field(sa_column=Column(String(32), nullable=False))
title: str = Field(sa_column=Column(String(255), nullable=False))
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
# ─── Журнал аудита ───────────────────────────────────────────────────────────
class AuditLog(SQLModel, table=True):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_audit_entity", "entity_type", "entity_id"),
Index("ix_audit_created", "created_at"),
)
id: int | None = Field(default=None, primary_key=True)
actor_id: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
)
action: str = Field(sa_column=Column(String(32), nullable=False))
entity_type: str = Field(sa_column=Column(String(32), nullable=False))
entity_id: int | None = Field(sa_column=Column(Integer, nullable=True))
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False)