Раньше уведомления удалялись через 72 часа после создания независимо от прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени чистятся только прочитанные (72ч после прочтения), а от роста у незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются старейшие сверх лимита. Механика доставки не менялась: запись в БД + pull при заходе, SSE лишь ускоряет обновление открытых вкладок. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
399 lines
17 KiB
Python
399 lines
17 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",
|
|
),
|
|
)
|
|
|
|
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,
|
|
)
|
|
)
|
|
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; в БД только метаданные и относительный путь.
|