Files
ForbiddenStarsApp/backend/app/models.py
T
NotBigGhostandClaude Opus 5 be80891449 Профиль: история партий игрока и настройки её витрины
Выборки партий по игроку в бэкенде не было — только по группе. Добавлен
stats_service.user_match_list: завершённые партии игрока, свежие сверху;
сборка элементов вынесена из group_match_list в общий _match_items, чтобы
не дублировать её в двух местах.

Витрина профиля задаётся двумя колонками в users (миграция 0011):
history_mode (all/best) и history_detail (compact/full). Режим применяется
на бэкенде, а не на клиенте: это витрина владельца, и в том же виде
профиль видят гости. В режиме best берётся партия с максимальными League
Points из SCORED_CTE (при равных очках — более свежая).

GET /api/users/{user_id}/matches отдаёт список вместе с mode и detail —
гостю хватает одного запроса, чтобы отрисовать историю как задумал владелец.

#1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:56:06 +03:00

423 lines
18 KiB
Python

"""SQLModel-модели (слой БД). Все таблицы v1 + заготовка под вложения.
Имена столбцов/таблиц — английские; отображаемые имена справочников (RU) — в `name_ru`.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
Column,
Date,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlmodel import Field, SQLModel
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
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 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)
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
# class Attachment(SQLModel, table=True):
# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL),
# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type,
# size_bytes, created_at
# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь.