Результаты (места и причина победы) теперь пишутся только в завершённую партию. Раньше их можно было проставить идущей: партия оставалась in_progress, висела в «Незавершённых», в статистику не попадала и очков не приносила — победитель есть, а игры как бы не было. Дату и общий комментарий по ходу партии править по-прежнему можно: двойственного состояния они не создают. Форма завершения получила общий черновик (match_finish_drafts): раскладка мест, ничьи, выбывшие, комментарии и причина победы видны всем, кто заполняет партию. Отдельная таблица, а не колонки в matches, намеренно — запись в строку партии дёргает onupdate у updated_at, то есть версию для оптимистичной блокировки, и «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку. Черновик удаляется при завершении и уходит каскадом при удалении партии. Черновик разъезжается отдельным типом SSE-события: он меняется на каждое движение тайла, и полная инвалидация (лидерборд, история, профили) по нему была бы расточительной. Автору правки событие не шлётся. #28 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
560 lines
16 KiB
Python
560 lines
16 KiB
Python
"""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
|
||
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
|
||
favorite_faction_id: int | None = None
|
||
# Витрина истории партий в профиле.
|
||
history_mode: str = "all"
|
||
history_detail: str = "compact"
|
||
|
||
|
||
class MeRead(UserRead):
|
||
groups: list[GroupBrief] = []
|
||
|
||
|
||
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 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
|
||
|
||
|
||
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
|