"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" from __future__ import annotations from datetime import date from typing import Literal from pydantic import BaseModel, ConfigDict, Field WinReason = Literal["objectives", "worlds", "plastic", "resources"] # ─── Auth ──────────────────────────────────────────────────────────────────── class AuthConfig(BaseModel): # Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве. methods: list[str] = [] telegram_bot_username: str | None = None 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 class MeRead(UserRead): groups: list[GroupBrief] = [] class NicknameUpdate(BaseModel): nickname: str class ProfileUpdate(BaseModel): bio: 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 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 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] = [] # ─── Статистика ────────────────────────────────────────────────────────────── 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 Leaderboard(BaseModel): entries: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = [] min_games: int class FactionStat(BaseModel): faction_id: int code: str name_ru: 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 most_played_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 class AdminUserUpdate(BaseModel): nickname: str | None = None is_active: bool | None = None 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