"""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)