Результаты (места и причина победы) теперь пишутся только в завершённую партию. Раньше их можно было проставить идущей: партия оставалась 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
448 lines
19 KiB
Python
448 lines
19 KiB
Python
"""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)
|
||
|
||
|