"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" from __future__ import annotations from datetime import date from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field # last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно # тогда, когда невыбывший участник один (match_service._check_last_standing). WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"] # Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры # ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа. EndRound = Annotated[int, Field(ge=1, le=9)] Count = Annotated[int, Field(ge=0, le=99)] # ─── 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 GroupUpdate(BaseModel): # Частичная правка: переданные поля меняются, остальные остаются как есть. name: str | None = None nine_rounds_rule: bool | None = None class GroupExpansionsUpdate(BaseModel): expansion_ids: list[int] = [] class GroupDetail(BaseModel): id: int name: str owner_id: int my_role: str expansion_ids: list[int] = [] # Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте). nine_rounds_rule: bool = False 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 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 # опц. смена фракции при завершении objectives: Count | None = None # маркеры целей на конец партии (необязательно) worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0 class MatchFinish(BaseModel): participants: list[MatchFinishParticipant] win_reason: WinReason end_round: EndRound | None = None # раунд, в котором партия закончилась 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 objectives: Count | None = None worlds: Count | None = None class MatchUpdate(BaseModel): played_at: date | None = None overall_comment: str | None = None win_reason: WinReason | None = None end_round: EndRound | 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 objectives: int | None = None worlds: int | 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 end_round: EndRound | None = None # Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают. objectives: dict[str, Count] = {} worlds: dict[str, Count] = {} 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 end_round: int | None = None # Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии: # фронт берёт лимит отсюда, а не повторяет правило у себя. nine_rounds_rule: bool = False max_rounds: int 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 objectives: int | None = None worlds: int | 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