v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
"""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))
|
||||
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"))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _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 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))
|
||||
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 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; в БД только метаданные и относительный путь.
|
||||
Reference in New Issue
Block a user