Files
ForbiddenStarsApp/backend/app/schemas/api.py
T
NotBigGhostandClaude Opus 5 c73b4519cb Пароль и привязка Telegram в профиле, пароль игрока из админки
PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.

POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.

PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.

#24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
2026-09-13 14:02:19 +03:00

591 lines
17 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):
# Доступные методы входа: ["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 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
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