"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" from __future__ import annotations from datetime import date, datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field WinReason = Literal["objectives", "worlds", "plastic", "resources"] # ─── Auth ──────────────────────────────────────────────────────────────────── class AuthConfig(BaseModel): # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. methods: list[str] = [] telegram_bot_username: str | None = None # Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt. # Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py. _PASSWORD_MAX_CHARS = 128 class PasswordLogin(BaseModel): # Логин — это ник игрока. nickname: str password: str = Field(max_length=_PASSWORD_MAX_CHARS) class PasswordRegister(BaseModel): nickname: str password: str = Field(max_length=_PASSWORD_MAX_CHARS) class PasswordChange(BaseModel): # current_password нужен, только если пароль уже задан; первая установка — без него. current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS) new_password: str = Field(max_length=_PASSWORD_MAX_CHARS) class TelegramAuthPayload(BaseModel): # Полезная нагрузка Telegram Login Widget (проверяется по HMAC). model_config = ConfigDict(extra="allow") id: int auth_date: int hash: str first_name: str | None = None last_name: str | None = None username: str | None = None photo_url: str | None = None class TelegramRegister(TelegramAuthPayload): # Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен). # Подпись виджета проверяется по тем же полям; nickname в HMAC не входит. nickname: str class DevLogin(BaseModel): nickname: str class DevUserCreate(BaseModel): nickname: str class DevUserRead(BaseModel): id: int nickname: str is_active: bool = True class OkResponse(BaseModel): ok: bool = True # ─── Справочники ───────────────────────────────────────────────────────────── class ExpansionRead(BaseModel): id: int code: str name_ru: str is_base: bool class FactionRead(BaseModel): id: int code: str name_ru: str expansion_id: int class FactionRename(BaseModel): name_ru: str # ─── Пользователь ──────────────────────────────────────────────────────────── class GroupBrief(BaseModel): id: int name: str role: str class UserRead(BaseModel): id: int nickname: str role: str auth_provider: str telegram_id: int | None = None active_group_id: int | None = None bio: str | None = None avatar_url: str | None = None # Любимая фракция — выбор игрока (id справочника); None — не выбрана. favorite_faction_id: int | None = None # Витрина истории партий в профиле. history_mode: str = "all" history_detail: str = "compact" class MeRead(UserRead): groups: list[GroupBrief] = [] # False — пароль ещё не задан (аккаунт из Telegram или до появления паролей): # фронт не пускает дальше окна установки пароля. has_password: bool = False class NicknameUpdate(BaseModel): nickname: str class ProfileUpdate(BaseModel): # Оба поля необязательны и обновляются, только если реально переданы # (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию. bio: str | None = None favorite_faction_id: int | None = None history_mode: str | None = None history_detail: str | None = None class ActiveGroupUpdate(BaseModel): group_id: int | None = None class NicknameAvailable(BaseModel): available: bool class UserSuggestion(BaseModel): """Подсказка автокомплита по нику (приглашение в группу и т.п.).""" user_id: int nickname: str avatar_url: str | None = None # ─── Группы и членство ─────────────────────────────────────────────────────── class GroupCreate(BaseModel): name: str expansion_ids: list[int] = [] class GroupRename(BaseModel): name: str class GroupExpansionsUpdate(BaseModel): expansion_ids: list[int] = [] class GroupDetail(BaseModel): id: int name: str owner_id: int my_role: str expansion_ids: list[int] = [] class MemberRead(BaseModel): user_id: int nickname: str role: str avatar_url: str | None = None class MemberAdd(BaseModel): nickname: str class MemberRoleUpdate(BaseModel): role: str class InvitationRead(BaseModel): id: int group_id: int group_name: str invited_by_nickname: str | None = None created_at: str # ─── Уведомления ─────────────────────────────────────────────────────────────── class NotificationRead(BaseModel): id: int type: str title: str body: str | None = None link: str | None = None read_at: str | None = None created_at: str class NotificationList(BaseModel): items: list[NotificationRead] unread_count: int class NotificationMarkRead(BaseModel): ids: list[int] | None = None # ─── Объявления (игрок) ────────────────────────────────────────────────────── class AnnouncementRead(BaseModel): id: int title: str # HTML, уже очищенный сервером по белому списку — фронт вставляет как есть. body_html: str revision: int # Игрок закрывал прежнюю версию — окно показывает пометку «обновлено». updated: bool = False class AnnouncementAck(BaseModel): # Версия, которую игрок видел и закрыл (AnnouncementRead.revision). revision: int = Field(ge=1) # ─── Партии ────────────────────────────────────────────────────────────────── class RandomizeRequest(BaseModel): group_id: int exclude_faction_ids: list[int] = [] class RandomizeResponse(BaseModel): faction: FactionRead # Этап 1 (старт): только ростер, без мест. class RosterParticipant(BaseModel): user_id: int faction_id: int was_random: bool = False class MatchCreate(BaseModel): group_id: int participants: list[RosterParticipant] # Этап 2 (завершение): места, комментарии, причина победы. class MatchFinishParticipant(BaseModel): user_id: int place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated) eliminated: bool = False # выбыл из партии → авто-проставится последнее место comment: str | None = None faction_id: int | None = None # опц. смена фракции при завершении class MatchFinish(BaseModel): participants: list[MatchFinishParticipant] win_reason: WinReason overall_comment: str | None = None # Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version). expected_version: str | None = None # Полный участник (правка завершённой партии админом). class ParticipantInput(BaseModel): user_id: int faction_id: int place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated) eliminated: bool = False was_random: bool = False comment: str | None = None class MatchUpdate(BaseModel): played_at: date | None = None overall_comment: str | None = None win_reason: WinReason | None = None participants: list[ParticipantInput] | None = None expected_version: str | None = None # оптимистичная блокировка class MatchParticipantRead(BaseModel): user_id: int nickname: str faction_id: int faction_name: str place: int | None = None eliminated: bool = False was_random: bool comment: str | None = None avatar_url: str | None = None class AttachmentRead(BaseModel): id: int kind: str # 'photo' (задел под видео) url: str mime_type: str size_bytes: int created_at: str class MatchFinishDraftData(BaseModel): """Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие, комментарии об игроках и причина победы. Промежуточное состояние, поэтому места не валидируются — человек раскладывает их постепенно.""" blocks: list[list[int]] = [] eliminated: list[int] = [] comments: dict[str, str] = {} win_reason: WinReason | None = None overall_comment: str | None = None class MatchFinishDraftRead(BaseModel): data: MatchFinishDraftData updated_by: int | None = None updated_by_nickname: str | None = None updated_at: str class MatchRead(BaseModel): id: int group_id: int status: str played_at: date started_at: str | None = None finished_at: str | None = None duration_minutes: int | None = None win_reason: WinReason | None = None player_count: int overall_comment: str | None = None created_by: int can_modify: bool = False # может ли текущий зритель править/завершать партию version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно participants: list[MatchParticipantRead] = [] attachments: list[AttachmentRead] = [] # Общий черновик формы завершения (только у незавершённой партии). finish_draft: MatchFinishDraftRead | None = None # ─── Статистика ────────────────────────────────────────────────────────────── class OverallStats(BaseModel): games: int wins: int win_rate: float avg_place: float | None = None score: float | None = None class LeaderboardEntry(OverallStats): user_id: int nickname: str rank: int | None = None avatar_url: str | None = None class MatchHistory(BaseModel): """История партий игрока плюс настройки витрины его профиля. Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так, как выбрал её владелец, не делая второго запроса за профилем.""" items: list[MatchListItem] = [] total: int limit: int offset: int mode: str detail: str class Leaderboard(BaseModel): entries: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = [] min_games: int class FactionStat(BaseModel): faction_id: int code: str name_ru: str # Название в предложном падеже — для строки «Чаще всего играет на …». name_ru_prepositional: str expansion_code: str games: int wins: int win_rate: float avg_place: float | None = None score: float | None = None class RecentFormItem(BaseModel): place: int player_count: int played_at: str class ProfileStats(BaseModel): user_id: int overall: OverallStats factions: list[FactionStat] = [] best_faction: FactionStat | None = None worst_faction: FactionStat | None = None # Любимая — личный выбор игрока в профиле (не статистика). favorite_faction: FactionRead | None = None # «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи. main_faction: FactionStat | None = None recent_form: list[RecentFormItem] = [] # Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал # неподтверждённый рейтинг, не дублируя константу на фронте. min_games: int = 0 class PublicProfile(BaseModel): # Профиль другого игрока (read-only): шапка + глобальная статистика. user_id: int nickname: str bio: str | None = None avatar_url: str | None = None stats: ProfileStats class FactionMeta(BaseModel): faction_id: int code: str name_ru: str games: int wins: int available: bool class GroupStats(BaseModel): group_id: int total_matches: int last_match_at: str | None = None leaderboard: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = [] inactive: list[LeaderboardEntry] = [] # участники без завершённых партий faction_meta: list[FactionMeta] = [] min_games: int class MatchListParticipant(BaseModel): user_id: int nickname: str faction_id: int faction_name: str place: int | None = None eliminated: bool = False was_random: bool comment: str | None = None class MatchListItem(BaseModel): id: int status: str played_at: str started_at: str | None = None finished_at: str | None = None duration_minutes: int | None = None win_reason: WinReason | None = None player_count: int overall_comment: str | None = None created_by: int participants: list[MatchListParticipant] = [] class MatchList(BaseModel): items: list[MatchListItem] = [] total: int limit: int offset: int class GroupBriefStats(OverallStats): id: int name: str class HomeInProgressMatch(BaseModel): id: int group_id: int group_name: str started_at: str | None = None player_count: int participants: list[MatchListParticipant] = [] class HomeResponse(BaseModel): leaderboard: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = [] profile: ProfileStats active_group: GroupBriefStats | None = None in_progress: list[HomeInProgressMatch] = [] min_games: int # ─── Админ ─────────────────────────────────────────────────────────────────── class AdminLogin(BaseModel): username: str password: str class AdminMe(BaseModel): id: int nickname: str role: str class AdminUserRead(BaseModel): id: int nickname: str role: str is_active: bool auth_provider: str telegram_id: int | None = None created_at: str has_password: bool = False class AdminUserUpdate(BaseModel): nickname: str | None = None is_active: bool | None = None class AdminPasswordSet(BaseModel): # Новый пароль игроку от админа — способ восстановить забытый пароль. new_password: str = Field(max_length=_PASSWORD_MAX_CHARS) class AdminGroupRead(BaseModel): id: int name: str owner_id: int created_at: str class AdminMatchRead(BaseModel): id: int group_id: int group_name: str | None = None status: str played_at: str duration_minutes: int | None = None win_reason: WinReason | None = None player_count: int created_by: int created_at: str class AchievementRead(BaseModel): slug: str name: str description: str = "" condition: str = "" # текст condition.py (задел; не исполняется) has_condition: bool = False icon_url: str | None = None class AchievementCreate(BaseModel): name: str description: str | None = None condition: str | None = None class AchievementUpdate(BaseModel): name: str | None = None description: str | None = None condition: str | None = None class AuditLogItem(BaseModel): id: int actor_id: int | None = None action: str entity_type: str entity_id: int | None = None payload: dict | None = None ip: str | None = None user_agent: str | None = None created_at: str class AuditLogList(BaseModel): items: list[AuditLogItem] = [] limit: int offset: int # ─── Объявления (админ) ────────────────────────────────────────────────────── AnnouncementStatus = Literal["planned", "live", "finished"] class AnnouncementWrite(BaseModel): # Точные правила (заголовок до 60 символов, текст до 600 видимых) — в # announcement_service; здесь только отсечка мегабайтных тел. title: str = Field(max_length=200) body_html: str = Field(max_length=20_000) starts_at: datetime ends_at: datetime show_to_new_players: bool = True class AnnouncementUpdate(AnnouncementWrite): # Показать заново тем, кто уже закрыл (с пометкой «обновлено»). reshow: bool = False class AdminAnnouncementRead(BaseModel): id: int title: str body_html: str starts_at: str ends_at: str show_to_new_players: bool revision: int status: AnnouncementStatus # Закрыли текущую версию / сколько активных игроков в адресатах. seen_count: int audience_count: int created_at: str updated_at: str