Files
ForbiddenStarsApp/backend/app/schemas/api.py
T
NotBigGhostandClaude Opus 5 8be9a23c0c Партия: общий черновик завершения и запрет результатов до финиша
Результаты (места и причина победы) теперь пишутся только в завершённую партию.
Раньше их можно было проставить идущей: партия оставалась 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
2026-09-09 19:39:31 +03:00

560 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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