All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 12s
A gamer's periodic check would get permanently stuck if their stored token was revoked/expired: our stats API collapsed both "Lichess rejected the token" (401/403, permanent) and genuine transient errors into the same 502 response, so the bot treated an invalid token exactly like a network blip — retrying the same window forever at a capped 300s backoff, never advancing the checkpoint (observed in prod: Dor1zz stuck for 100+ consecutive errors over 8+ hours, admin alerts firing every 25 failures). Preserve the distinction that already existed one layer down (lichess_client.py already tells 401/403 apart from other failures) instead of collapsing it in stats_service.py: add PuzzleOfPeriodResponse.auth_failed, have main.py return 401 specifically for that case, and have the bot raise a distinct InvalidTokenError instead of returning None. On InvalidTokenError, the bot now clears the token for that pair, notifies the user to reconnect via /addtoken, and continues tracking games normally instead of stalling forever.
261 lines
18 KiB
Python
261 lines
18 KiB
Python
"""
|
||
Lichess Statistics API - Модели данных
|
||
|
||
Этот модуль содержит все Pydantic модели для валидации и сериализации данных.
|
||
Модели используются для:
|
||
- Валидации входных параметров API
|
||
- Сериализации ответов API
|
||
- Документации в Swagger UI
|
||
- Типизации данных в коде
|
||
|
||
Автор: Lichess Web Services Team
|
||
Версия: 1.0.0
|
||
"""
|
||
|
||
from pydantic import BaseModel, Field
|
||
from typing import Dict, List, Optional, Any
|
||
|
||
# =============================================================================
|
||
# МОДЕЛИ СТАТИСТИКИ ЗАДАЧ (ПАЗЛОВ)
|
||
# =============================================================================
|
||
|
||
class TaskStats(BaseModel):
|
||
"""
|
||
Статистика решения задач (пазлов) пользователя.
|
||
|
||
Содержит информацию о том, сколько задач пользователь решил,
|
||
сколько решил правильно и сколько не решил или решил неправильно.
|
||
"""
|
||
total: int = Field(..., description="Общее количество решенных задач", example=15)
|
||
solved: int = Field(..., description="Количество правильно решенных задач", example=12)
|
||
unsolved: int = Field(..., description="Количество нерешенных или неправильно решенных задач", example=3)
|
||
|
||
# =============================================================================
|
||
# МОДЕЛИ СТАТИСТИКИ ИГР
|
||
# =============================================================================
|
||
|
||
class GameModeStats(BaseModel):
|
||
"""
|
||
Статистика игр для конкретного режима (Bullet, Blitz, Rapid и т.д.).
|
||
|
||
Содержит полную статистику по играм в определенном временном формате:
|
||
- Количество сыгранных игр
|
||
- Изменение рейтинга за период
|
||
- Текущий рейтинг
|
||
- Результаты игр (победы, поражения, ничьи)
|
||
"""
|
||
games_played: int = Field(..., description="Общее количество сыгранных игр", example=8)
|
||
rating_change: int = Field(..., description="Изменение рейтинга (может быть отрицательным)", example=15)
|
||
final_rating: int = Field(..., description="Текущий рейтинг игрока", example=2850)
|
||
wins: int = Field(..., description="Количество побед", example=5)
|
||
losses: int = Field(..., description="Количество поражений", example=2)
|
||
draws: int = Field(..., description="Количество ничьих", example=1)
|
||
|
||
class GamesStats(BaseModel):
|
||
"""
|
||
Статистика игр по всем режимам.
|
||
|
||
Агрегирует статистику игр по всем временным форматам:
|
||
- Bullet: быстрые игры (1-3 минуты)
|
||
- Blitz: блиц игры (3-10 минут)
|
||
- Rapid: рапид игры (10+ минут)
|
||
- Classical: классические игры (30+ минут)
|
||
"""
|
||
bullet: GameModeStats = Field(..., description="Статистика Bullet игр (1-3 минуты)")
|
||
blitz: GameModeStats = Field(..., description="Статистика Blitz игр (3-10 минут)")
|
||
rapid: GameModeStats = Field(..., description="Статистика Rapid игр (10+ минут)")
|
||
classical: GameModeStats = Field(..., description="Статистика Classical игр (30+ минут)")
|
||
|
||
class UserStats(BaseModel):
|
||
"""
|
||
Полная статистика пользователя.
|
||
|
||
Содержит всю доступную статистику по пользователю:
|
||
- Статистику решения задач (пазлов)
|
||
- Статистику игр по всем режимам
|
||
"""
|
||
username: str = Field(..., description="Имя пользователя на Lichess", example="magnus")
|
||
tasks: TaskStats = Field(..., description="Статистика решения задач")
|
||
games: GamesStats = Field(..., description="Статистика игр по всем режимам")
|
||
|
||
# =============================================================================
|
||
# МОДЕЛИ ОТВЕТОВ API
|
||
# =============================================================================
|
||
|
||
class ActivityResponse(BaseModel):
|
||
"""
|
||
Стандартный ответ API с результатами запроса статистики.
|
||
|
||
Используется для всех эндпоинтов статистики (сегодня, вчера, неделя).
|
||
Содержит сообщение о результате и данные статистики пользователя.
|
||
"""
|
||
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика за сегодняшний день")
|
||
data: Optional[UserStats] = Field(None, description="Данные статистики пользователя (null если пользователь не найден или неактивен)")
|
||
|
||
class ErrorResponse(BaseModel):
|
||
"""
|
||
Модель для стандартизированных ошибок API.
|
||
|
||
Используется для возврата структурированных ошибок с дополнительной информацией.
|
||
"""
|
||
detail: str = Field(..., description="Описание ошибки", example="Пользователь не найден")
|
||
error_code: Optional[str] = Field(None, description="Код ошибки", example="USER_NOT_FOUND")
|
||
timestamp: Optional[str] = Field(None, description="Время возникновения ошибки", example="2024-01-15T10:30:00Z")
|
||
|
||
class HealthResponse(BaseModel):
|
||
"""
|
||
Ответ для health check эндпоинта.
|
||
|
||
Используется для мониторинга состояния сервиса и проверки его работоспособности.
|
||
"""
|
||
status: str = Field(..., description="Статус сервиса", example="healthy")
|
||
timestamp: str = Field(..., description="Время проверки", example="2024-01-15T10:30:00Z")
|
||
service: str = Field(..., description="Название сервиса", example="Lichess Statistics API")
|
||
|
||
# =============================================================================
|
||
# МОДЕЛИ ДЛЯ ЭНДПОИНТА СТАТИСТИКИ ИГР ЗА ПЕРИОД
|
||
# =============================================================================
|
||
|
||
class GamePlayer(BaseModel):
|
||
"""
|
||
Информация об игроке в партии.
|
||
|
||
Содержит данные о пользователе, его рейтинге и изменении рейтинга в конкретной игре.
|
||
"""
|
||
user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе")
|
||
rating: Optional[int] = Field(None, description="Рейтинг игрока")
|
||
ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга")
|
||
analysis: Optional[Dict[str, Any]] = Field(None, description="Пост-анализ партии (только если игрок запускал анализ на Lichess), содержит поле 'accuracy'")
|
||
|
||
class Game(BaseModel):
|
||
"""
|
||
Модель игры из Lichess API.
|
||
|
||
Содержит полную информацию об игре, включая:
|
||
- Метаданные игры (ID, время создания, статус)
|
||
- Информацию об игроках и их рейтингах
|
||
- Результат игры и ходы в PGN формате
|
||
"""
|
||
id: str = Field(..., description="ID игры")
|
||
rated: bool = Field(..., description="Рейтинговая ли игра")
|
||
variant: str = Field(..., description="Вариант игры")
|
||
speed: str = Field(..., description="Скорость игры (bullet, blitz, rapid, classical, correspondence)")
|
||
perf: str = Field(..., description="Тип производительности")
|
||
createdAt: int = Field(..., description="Время создания игры (timestamp)")
|
||
lastMoveAt: int = Field(..., description="Время последнего хода (timestamp)")
|
||
status: str = Field(..., description="Статус игры")
|
||
players: Dict[str, GamePlayer] = Field(..., description="Игроки (white, black)")
|
||
winner: Optional[str] = Field(None, description="Победитель (white, black или null)")
|
||
moves: str = Field(..., description="Ходы игры в PGN формате")
|
||
|
||
class GameRow(BaseModel):
|
||
"""
|
||
Одна отдельная партия для построчного разбора (today/yesterday/периодические уведомления).
|
||
|
||
Порядок игроков — как в партии (белые/чёрные), а не "отслеживаемый пользователь/соперник".
|
||
"""
|
||
white_name: str = Field(..., description="Имя игрока белыми")
|
||
black_name: str = Field(..., description="Имя игрока чёрными")
|
||
white_rating: Optional[int] = Field(None, description="Рейтинг белых")
|
||
black_rating: Optional[int] = Field(None, description="Рейтинг чёрных")
|
||
white_accuracy: Optional[float] = Field(None, description="Точность белых, если партия анализировалась")
|
||
black_accuracy: Optional[float] = Field(None, description="Точность чёрных, если партия анализировалась")
|
||
result: str = Field(..., description="Результат партии: '1-0', '0-1' или '1/2-1/2'")
|
||
created_at: int = Field(..., description="Время создания партии (Unix timestamp в миллисекундах)")
|
||
tracked_is_white: Optional[bool] = Field(None, description="True, если отслеживаемый пользователь играл белыми; False — чёрными; None — не удалось определить сторону")
|
||
|
||
class GameStats(BaseModel):
|
||
"""
|
||
Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.).
|
||
|
||
Содержит агрегированную статистику по играм определенного типа:
|
||
- Количество сыгранных игр
|
||
- Результаты игр (победы, поражения, ничьи)
|
||
- Общее изменение рейтинга
|
||
- Итоговый рейтинг после последней игры
|
||
"""
|
||
games_played: int = Field(..., description="Общее количество сыгранных игр", example=10)
|
||
wins: int = Field(..., description="Количество побед", example=6)
|
||
losses: int = Field(..., description="Количество поражений", example=3)
|
||
draws: int = Field(..., description="Количество ничьих", example=1)
|
||
rating_change: int = Field(..., description="Общее изменение рейтинга", example=15)
|
||
rating: Optional[int] = Field(None, description="Итоговый рейтинг после последней игры (только если games_played > 0)", example=2850)
|
||
accuracy: Optional[float] = Field(None, description="Средняя точность отслеживаемого пользователя по проанализированным партиям этого режима; None если нет проанализированных партий", example=82.5)
|
||
games: Optional[List[GameRow]] = Field(None, description="Построчный список отдельных партий (только blitz/rapid/classical), заполняется только если запрошено include_games=True")
|
||
|
||
class GamesOfPeriodStats(BaseModel):
|
||
"""
|
||
Статистика игр за период по всем типам.
|
||
|
||
Агрегирует статистику игр по всем временным форматам:
|
||
- Bullet, Blitz, Rapid, Classical, Correspondence
|
||
- Общая статистика по всем типам
|
||
"""
|
||
bullet: GameStats = Field(..., description="Статистика Bullet игр")
|
||
blitz: GameStats = Field(..., description="Статистика Blitz игр")
|
||
rapid: GameStats = Field(..., description="Статистика Rapid игр")
|
||
classical: GameStats = Field(..., description="Статистика Classical игр")
|
||
correspondence: GameStats = Field(..., description="Статистика Correspondence игр")
|
||
total: GameStats = Field(..., description="Общая статистика по всем типам")
|
||
|
||
class GamesOfPeriodResponse(BaseModel):
|
||
"""
|
||
Ответ API с результатами запроса статистики игр за период.
|
||
|
||
Содержит метаинформацию о запросе и агрегированную статистику игр.
|
||
"""
|
||
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика игр за период")
|
||
success: bool = Field(True, description="False, если запрос к Lichess завершился ошибкой (а не легитимным нулевым результатом)", example=True)
|
||
username: str = Field(..., description="Имя пользователя", example="magnus")
|
||
period_start: int = Field(..., description="Начало периода (Unix timestamp)", example=1640995200)
|
||
period_end: int = Field(..., description="Конец периода (Unix timestamp)", example=1641081600)
|
||
games_count: int = Field(..., description="Общее количество игр", example=25)
|
||
earliest_game_ts: Optional[int] = Field(None, description="Время самой старой найденной партии (Unix timestamp, секунды)", example=1638316800)
|
||
data: Optional[GamesOfPeriodStats] = Field(None, description="Данные статистики игр")
|
||
|
||
# =============================================================================
|
||
# МОДЕЛИ ДЛЯ ЭНДПОИНТА СТАТИСТИКИ ЗАДАЧ ЗА ПЕРИОД
|
||
# =============================================================================
|
||
|
||
class PuzzleActivity(BaseModel):
|
||
"""
|
||
Активность по решению задачи (пазла) из Lichess API.
|
||
|
||
Содержит информацию о попытке решения задачи пользователем:
|
||
- ID задачи и время решения
|
||
- Результат решения (решена/не решена)
|
||
- Дополнительная информация о задаче
|
||
"""
|
||
id: str = Field(..., description="ID задачи", example="abc123")
|
||
createdAt: int = Field(..., description="Время создания активности (timestamp в миллисекундах)", example=1640995200000)
|
||
win: bool = Field(..., description="Решена ли задача правильно", example=True)
|
||
puzzle: Dict[str, Any] = Field(..., description="Информация о задаче")
|
||
|
||
class PuzzleStats(BaseModel):
|
||
"""
|
||
Агрегированная статистика решения задач за период.
|
||
|
||
Содержит сводную информацию о решении задач:
|
||
- Общее количество попыток
|
||
- Количество успешных и неуспешных решений
|
||
- Процент успешности
|
||
"""
|
||
total_attempts: int = Field(..., description="Общее количество попыток решения", example=25)
|
||
solved: int = Field(..., description="Количество решенных задач", example=18)
|
||
failed: int = Field(..., description="Количество нерешенных задач", example=7)
|
||
success_rate: float = Field(..., description="Процент успешных решений", example=72.0)
|
||
|
||
class PuzzleOfPeriodResponse(BaseModel):
|
||
"""
|
||
Ответ API с результатами запроса статистики решения задач за период.
|
||
|
||
Содержит метаинформацию о запросе и агрегированную статистику решения задач.
|
||
"""
|
||
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика решения задач за период")
|
||
success: bool = Field(True, description="False, если запрос к Lichess завершился ошибкой (а не легитимным нулевым результатом)", example=True)
|
||
auth_failed: bool = Field(False, description="True, если Lichess отклонил токен (401/403) — permanent, не стоит ретраить; отличается от транзитных ошибок")
|
||
period_start: int = Field(..., description="Начало периода (Unix timestamp в миллисекундах)", example=1640995200000)
|
||
period_end: int = Field(..., description="Конец периода (Unix timestamp в миллисекундах)", example=1641081600000)
|
||
max_puzzles: int = Field(..., description="Максимальное количество задач для получения", example=50)
|
||
puzzles_in_period: int = Field(..., description="Количество задач в указанном периоде", example=15)
|
||
data: Optional[PuzzleStats] = Field(None, description="Данные статистики решения задач")
|