Files
ForbiddenStarsApp/backend/app/models.py
T
NotBigGhostandClaude Opus 5 8be9a23c0c Партия: общий черновик завершения и запрет результатов до финиша
Результаты (места и причина победы) теперь пишутся только в завершённую партию.
Раньше их можно было проставить идущей: партия оставалась 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
2026-09-09 19:39:31 +03:00

448 lines
19 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))
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,
)
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')",
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))
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))
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)