Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.
Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.
API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.
Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
683 lines
22 KiB
Python
683 lines
22 KiB
Python
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
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 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 # опц. смена фракции при завершении
|
||
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
|
||
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
|
||
score: int | None = None
|
||
|
||
|
||
class LeaderboardEntry(OverallStats):
|
||
user_id: int
|
||
nickname: str
|
||
rank: int | None = None
|
||
avatar_url: str | None = None
|
||
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
|
||
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
|
||
rating_confirmed: bool = False
|
||
|
||
|
||
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
|
||
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
|
||
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
|
||
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] = []
|
||
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
|
||
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
|
||
rating_delta: float | None = None
|
||
|
||
|
||
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
|