add per-game accuracy and outcome table to stats/notifications
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
Games-of-period responses can now include a per-game breakdown
(include_games) with Lichess post-analysis accuracy, plus which side
the tracked player was on and their rating change for that game.
- lichess_client.py requests accuracy=true from Lichess; stats_service
computes per-mode average accuracy and builds GameRow entries
(tracked_is_white, tracked_rating_diff) for blitz/rapid/classical
- /games/{username}/period gained an include_games query param
- formatters.py renders a column-aligned monospace table per game:
outcome circle (win/loss/draw relative to the tracked player),
rating change, accuracy, names/ratings, result — for today/yesterday
and periodic notifications; week keeps an aggregate accuracy line
- usernames are Markdown-escaped before formatting since messages are
now sent with parse_mode='Markdown'
This commit is contained in:
parent
4a783225af
commit
d479e14bf9
8 changed files with 364 additions and 75 deletions
|
|
@ -1070,8 +1070,40 @@ class LichessBot:
|
||||||
|
|
||||||
# Only send response if there's activity
|
# Only send response if there's activity
|
||||||
if has_activity:
|
if has_activity:
|
||||||
formatted_response = StatsFormatter.format_stats_response(data, username, period, lang)
|
# Дополнительный, параллельный запрос за реальными партиями периода —
|
||||||
await update.message.reply_text(formatted_response)
|
# только чтобы добавить точность (accuracy) и, для today/yesterday,
|
||||||
|
# построчный разбор партий. Шапку (данные из /activity) не трогаем:
|
||||||
|
# при любой ошибке здесь просто не покажем точность.
|
||||||
|
accuracy_extra = None
|
||||||
|
try:
|
||||||
|
now_dt = datetime.now()
|
||||||
|
if period == "today":
|
||||||
|
since_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
until_dt = now_dt
|
||||||
|
want_rows = True
|
||||||
|
elif period == "yesterday":
|
||||||
|
today_midnight = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
since_dt = today_midnight - timedelta(days=1)
|
||||||
|
until_dt = today_midnight
|
||||||
|
want_rows = True
|
||||||
|
else: # week — то же окно (today-6 .. now), что использует _is_date_in_range(days_back=7)
|
||||||
|
since_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=6)
|
||||||
|
until_dt = now_dt
|
||||||
|
want_rows = False
|
||||||
|
|
||||||
|
accuracy_extra = await self.lichess_api.get_games_period(
|
||||||
|
username,
|
||||||
|
int(since_dt.timestamp() * 1000),
|
||||||
|
int(until_dt.timestamp() * 1000),
|
||||||
|
rated_only=True,
|
||||||
|
include_games=want_rows
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Supplementary accuracy fetch failed for {username}/{period}: {e}")
|
||||||
|
accuracy_extra = None
|
||||||
|
|
||||||
|
formatted_response = StatsFormatter.format_stats_response(data, username, period, lang, accuracy_extra=accuracy_extra)
|
||||||
|
await update.message.reply_text(formatted_response, parse_mode='Markdown')
|
||||||
has_any_activity = True
|
has_any_activity = True
|
||||||
else:
|
else:
|
||||||
logger.info(f"ℹ️ No activity found for {username}, skipping response")
|
logger.info(f"ℹ️ No activity found for {username}, skipping response")
|
||||||
|
|
@ -1159,7 +1191,7 @@ class LichessBot:
|
||||||
if games_count > 0:
|
if games_count > 0:
|
||||||
# Format and send immediately
|
# Format and send immediately
|
||||||
text = StatsFormatter.format_last_year_or_1000(data, username, lang)
|
text = StatsFormatter.format_last_year_or_1000(data, username, lang)
|
||||||
await update.message.reply_text(text)
|
await update.message.reply_text(text, parse_mode='Markdown')
|
||||||
has_any_activity = True
|
has_any_activity = True
|
||||||
|
|
||||||
# Wait 3 seconds before next request (except after the last one)
|
# Wait 3 seconds before next request (except after the last one)
|
||||||
|
|
@ -1585,7 +1617,8 @@ class LichessBot:
|
||||||
logger.info(f"📥 Adding games request to queue for {gamer['username']}")
|
logger.info(f"📥 Adding games request to queue for {gamer['username']}")
|
||||||
games_data = await self.request_queue.add_request(
|
games_data = await self.request_queue.add_request(
|
||||||
self.lichess_api.get_games_period,
|
self.lichess_api.get_games_period,
|
||||||
gamer['username'], since_timestamp, until_timestamp_approx
|
gamer['username'], since_timestamp, until_timestamp_approx,
|
||||||
|
include_games=True
|
||||||
)
|
)
|
||||||
if games_data is None:
|
if games_data is None:
|
||||||
raise RuntimeError("Games period API returned no data")
|
raise RuntimeError("Games period API returned no data")
|
||||||
|
|
@ -1695,7 +1728,8 @@ class LichessBot:
|
||||||
|
|
||||||
await self.application.bot.send_message(
|
await self.application.bot.send_message(
|
||||||
chat_id=user_id,
|
chat_id=user_id,
|
||||||
text=notification
|
text=notification,
|
||||||
|
parse_mode='Markdown'
|
||||||
)
|
)
|
||||||
logger.info(f"✅ Sent periodic notification for {gamer['username']} to user {user_id}")
|
logger.info(f"✅ Sent periodic notification for {gamer['username']} to user {user_id}")
|
||||||
# Increment periodic notification counter
|
# Increment periodic notification counter
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,98 @@ class StatsFormatter:
|
||||||
return f"🔴 {rating_change}"
|
return f"🔴 {rating_change}"
|
||||||
else:
|
else:
|
||||||
return "⚪ 0"
|
return "⚪ 0"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en') -> str:
|
def _escape_md(text: str) -> str:
|
||||||
|
"""Escape literal Markdown-special chars in dynamic text (e.g. usernames) for legacy Telegram Markdown"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
for ch in ('_', '*', '`', '['):
|
||||||
|
text = text.replace(ch, '\\' + ch)
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_accuracy(accuracy: Optional[float]) -> str:
|
||||||
|
"""Format accuracy percentage, or '-' if not available (game not analyzed)"""
|
||||||
|
if accuracy is None:
|
||||||
|
return "-"
|
||||||
|
return f"{accuracy:.0f}%"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_result(result: str) -> str:
|
||||||
|
"""Format a raw '1-0'/'0-1'/'1/2-1/2' result string for display"""
|
||||||
|
return "½-½" if result == "1/2-1/2" else result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_row_outcome_circle(row: dict) -> str:
|
||||||
|
"""
|
||||||
|
Colored circle for the tracked user's outcome in this row:
|
||||||
|
🟢 win, 🔴 loss, ⚪ draw (or unknown side).
|
||||||
|
"""
|
||||||
|
tracked_is_white = row.get('tracked_is_white')
|
||||||
|
result = row.get('result', '')
|
||||||
|
if tracked_is_white is None or result == "1/2-1/2":
|
||||||
|
return "⚪"
|
||||||
|
tracked_won = (result == "1-0" and tracked_is_white) or (result == "0-1" and not tracked_is_white)
|
||||||
|
return "🟢" if tracked_won else "🔴"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_rating_diff_paren(diff: Optional[int]) -> str:
|
||||||
|
"""Format the tracked user's rating change for this game, e.g. '(+5)' / '(-7)' / '(0)'"""
|
||||||
|
diff = diff or 0
|
||||||
|
if diff > 0:
|
||||||
|
return f"(+{diff})"
|
||||||
|
elif diff < 0:
|
||||||
|
return f"({diff})"
|
||||||
|
else:
|
||||||
|
return "(0)"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_game_rows_block(rows: list) -> str:
|
||||||
|
"""
|
||||||
|
Format a list of individual game rows as a column-aligned, monospace table:
|
||||||
|
outcome | rating_diff | accuracy1 | White | rating1 | result | Black | rating2 | accuracy2
|
||||||
|
|
||||||
|
Column widths are computed from the actual data so every column lines up
|
||||||
|
character-for-character. Caller is expected to wrap the result in a
|
||||||
|
Markdown ``` code block ``` so Telegram renders it with a monospace font.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
NAME_WIDTH = 7
|
||||||
|
|
||||||
|
prepared = []
|
||||||
|
for row in rows:
|
||||||
|
circle = StatsFormatter._format_row_outcome_circle(row)
|
||||||
|
diff_str = StatsFormatter._format_rating_diff_paren(row.get('tracked_rating_diff'))
|
||||||
|
white = (row.get('white_name') or '?')[:NAME_WIDTH]
|
||||||
|
black = (row.get('black_name') or '?')[:NAME_WIDTH]
|
||||||
|
wr = row.get('white_rating')
|
||||||
|
br = row.get('black_rating')
|
||||||
|
wr_str = str(wr) if wr is not None else "-"
|
||||||
|
br_str = str(br) if br is not None else "-"
|
||||||
|
wa = StatsFormatter._format_accuracy(row.get('white_accuracy'))
|
||||||
|
ba = StatsFormatter._format_accuracy(row.get('black_accuracy'))
|
||||||
|
result = StatsFormatter._format_result(row.get('result', ''))
|
||||||
|
prepared.append((circle, diff_str, wa, white, wr_str, result, black, br_str, ba))
|
||||||
|
|
||||||
|
diff_width = max(len(p[1]) for p in prepared)
|
||||||
|
acc_width = max(max(len(p[2]), len(p[8])) for p in prepared)
|
||||||
|
name_width = NAME_WIDTH
|
||||||
|
rating_width = max(max(len(p[4]), len(p[7])) for p in prepared)
|
||||||
|
result_width = max(len(p[5]) for p in prepared)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for circle, diff_str, wa, white, wr_str, result, black, br_str, ba in prepared:
|
||||||
|
lines.append(
|
||||||
|
f"{circle} {diff_str:>{diff_width}} {wa:>{acc_width}} {white:<{name_width}} {wr_str:>{rating_width}} "
|
||||||
|
f"{result:^{result_width}} {black:<{name_width}} {br_str:>{rating_width}} {ba:>{acc_width}}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en', accuracy_extra: Optional[Dict[str, Any]] = None) -> str:
|
||||||
"""Format statistics response according to the template"""
|
"""Format statistics response according to the template"""
|
||||||
if not data or data.get('data') is None:
|
if not data or data.get('data') is None:
|
||||||
message = data.get('message', t('no_data', lang)) if data else t('no_data', lang)
|
message = data.get('message', t('no_data', lang)) if data else t('no_data', lang)
|
||||||
|
|
@ -39,45 +128,78 @@ class StatsFormatter:
|
||||||
unsolved = tasks.get('unsolved', 0)
|
unsolved = tasks.get('unsolved', 0)
|
||||||
task_text = t('puzzles_section', lang, total=total_tasks, solved=solved, unsolved=unsolved)
|
task_text = t('puzzles_section', lang, total=total_tasks, solved=solved, unsolved=unsolved)
|
||||||
|
|
||||||
|
# Supplementary per-mode accuracy (+ per-game rows for today/yesterday), from a parallel
|
||||||
|
# games-of-period fetch. Purely additive: absent/None just means no accuracy shown.
|
||||||
|
accuracy_by_mode = (accuracy_extra.get('data') or {}) if accuracy_extra else {}
|
||||||
|
|
||||||
# Format games section
|
# Format games section
|
||||||
games_text = ""
|
games_text = ""
|
||||||
if games:
|
if games:
|
||||||
for game_type, game_data in games.items():
|
for game_type, game_data in games.items():
|
||||||
if not game_data or game_data.get('games_played', 0) == 0:
|
if not game_data or game_data.get('games_played', 0) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get game type emoji
|
# Get game type emoji
|
||||||
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
||||||
|
|
||||||
games_count = game_data.get('games_played', 0)
|
games_count = game_data.get('games_played', 0)
|
||||||
rating_change = game_data.get('rating_change', 0)
|
rating_change = game_data.get('rating_change', 0)
|
||||||
rating = game_data.get('final_rating', 0)
|
rating = game_data.get('final_rating', 0)
|
||||||
wins = game_data.get('wins', 0)
|
wins = game_data.get('wins', 0)
|
||||||
losses = game_data.get('losses', 0)
|
losses = game_data.get('losses', 0)
|
||||||
draws = game_data.get('draws', 0)
|
draws = game_data.get('draws', 0)
|
||||||
|
|
||||||
# Format rating change
|
# Format rating change
|
||||||
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
||||||
|
|
||||||
# Get game type name (capitalize first letter)
|
# Get game type name (capitalize first letter)
|
||||||
game_type_name = game_type.title()
|
game_type_name = game_type.title()
|
||||||
|
|
||||||
games_text += t('games_section', lang,
|
# Точность в шапке имеет смысл только там, где нет построчного разбора партий
|
||||||
emoji=emoji,
|
# ниже (week) — для today/yesterday она была бы избыточна рядом со строками партий.
|
||||||
game_type=game_type_name,
|
if game_type in ('blitz', 'rapid', 'classical') and period == "week":
|
||||||
games_count=games_count,
|
mode_accuracy = (accuracy_by_mode.get(game_type) or {}).get('accuracy')
|
||||||
rating_change=rating_change_str,
|
games_text += t('games_section_with_accuracy', lang,
|
||||||
rating=rating,
|
emoji=emoji,
|
||||||
wins=wins,
|
game_type=game_type_name,
|
||||||
losses=losses,
|
games_count=games_count,
|
||||||
draws=draws
|
rating_change=rating_change_str,
|
||||||
)
|
rating=rating,
|
||||||
|
wins=wins,
|
||||||
|
losses=losses,
|
||||||
|
draws=draws,
|
||||||
|
accuracy=StatsFormatter._format_accuracy(mode_accuracy)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
games_text += t('games_section', lang,
|
||||||
|
emoji=emoji,
|
||||||
|
game_type=game_type_name,
|
||||||
|
games_count=games_count,
|
||||||
|
rating_change=rating_change_str,
|
||||||
|
rating=rating,
|
||||||
|
wins=wins,
|
||||||
|
losses=losses,
|
||||||
|
draws=draws
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-game row breakdown (today/yesterday only — week/lastYear stay aggregate-only)
|
||||||
|
rows_text = ""
|
||||||
|
if period in ("today", "yesterday"):
|
||||||
|
for mode in ("blitz", "rapid", "classical"):
|
||||||
|
rows = (accuracy_by_mode.get(mode) or {}).get('games') or []
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
emoji = StatsFormatter._get_game_type_emoji(mode)
|
||||||
|
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=mode.title())
|
||||||
|
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
|
||||||
|
|
||||||
# Combine all parts
|
# Combine all parts
|
||||||
result = t('stats_title', lang, username=username, date_range=date_range)
|
result = t('stats_title', lang, username=StatsFormatter._escape_md(username), date_range=date_range)
|
||||||
result += task_text
|
result += task_text
|
||||||
result += games_text.rstrip()
|
result += games_text.rstrip()
|
||||||
|
if rows_text:
|
||||||
|
result += "\n\n" + rows_text.rstrip()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -123,7 +245,7 @@ class StatsFormatter:
|
||||||
else:
|
else:
|
||||||
period_text = t('period_minutes_text', lang, period=period_minutes)
|
period_text = t('period_minutes_text', lang, period=period_minutes)
|
||||||
|
|
||||||
result = t('period_notification_title', lang, username=username, period_text=period_text)
|
result = t('period_notification_title', lang, username=StatsFormatter._escape_md(username), period_text=period_text)
|
||||||
|
|
||||||
# Format puzzles first (if available and there's actual activity)
|
# Format puzzles first (if available and there's actual activity)
|
||||||
has_puzzles_data = False
|
has_puzzles_data = False
|
||||||
|
|
@ -157,6 +279,7 @@ class StatsFormatter:
|
||||||
effective_games_count = top_level_games_count if top_level_games_count > 0 else total_games
|
effective_games_count = top_level_games_count if top_level_games_count > 0 else total_games
|
||||||
|
|
||||||
# Show details for each game type if there were games
|
# Show details for each game type if there were games
|
||||||
|
rows_text = ""
|
||||||
if effective_games_count > 0:
|
if effective_games_count > 0:
|
||||||
for game_type, game_data in games_info.items():
|
for game_type, game_data in games_info.items():
|
||||||
if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0:
|
if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0:
|
||||||
|
|
@ -168,25 +291,45 @@ class StatsFormatter:
|
||||||
wins = game_data.get('wins', 0)
|
wins = game_data.get('wins', 0)
|
||||||
losses = game_data.get('losses', 0)
|
losses = game_data.get('losses', 0)
|
||||||
draws = game_data.get('draws', 0)
|
draws = game_data.get('draws', 0)
|
||||||
|
|
||||||
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
||||||
game_type_name = game_type.title()
|
game_type_name = game_type.title()
|
||||||
|
|
||||||
result += t('period_games_section', lang,
|
if game_type in ('blitz', 'rapid', 'classical'):
|
||||||
emoji=emoji,
|
# Точность в шапку не выводим — она всегда дублируется построчным
|
||||||
game_type=game_type_name,
|
# разбором партий ниже (периодическая проверка — короткий период).
|
||||||
games_count=games_count,
|
result += t('period_games_section', lang,
|
||||||
rating_change=rating_change_str,
|
emoji=emoji,
|
||||||
rating=rating,
|
game_type=game_type_name,
|
||||||
wins=wins,
|
games_count=games_count,
|
||||||
losses=losses,
|
rating_change=rating_change_str,
|
||||||
draws=draws
|
rating=rating,
|
||||||
)
|
wins=wins,
|
||||||
|
losses=losses,
|
||||||
|
draws=draws
|
||||||
|
)
|
||||||
|
rows = game_data.get('games') or []
|
||||||
|
if rows:
|
||||||
|
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=game_type_name)
|
||||||
|
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
|
||||||
|
else:
|
||||||
|
result += t('period_games_section', lang,
|
||||||
|
emoji=emoji,
|
||||||
|
game_type=game_type_name,
|
||||||
|
games_count=games_count,
|
||||||
|
rating_change=rating_change_str,
|
||||||
|
rating=rating,
|
||||||
|
wins=wins,
|
||||||
|
losses=losses,
|
||||||
|
draws=draws
|
||||||
|
)
|
||||||
|
if rows_text:
|
||||||
|
result += rows_text
|
||||||
|
|
||||||
# If no activity at all
|
# If no activity at all
|
||||||
if not has_games_data and not has_puzzles_data:
|
if not has_games_data and not has_puzzles_data:
|
||||||
result += t('no_activity', lang)
|
result += t('no_activity', lang)
|
||||||
|
|
||||||
return result.rstrip()
|
return result.rstrip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -202,14 +345,15 @@ class StatsFormatter:
|
||||||
period_end = data.get('period_end')
|
period_end = data.get('period_end')
|
||||||
stats = (data.get('data') or {})
|
stats = (data.get('data') or {})
|
||||||
# Title and subheader
|
# Title and subheader
|
||||||
|
escaped_username = StatsFormatter._escape_md(username)
|
||||||
if games_count >= 1000:
|
if games_count >= 1000:
|
||||||
header = f"📈 {username}: last 1000 rated games"
|
header = f"📈 {escaped_username}: last 1000 rated games"
|
||||||
earliest_ts = data.get('earliest_game_ts')
|
earliest_ts = data.get('earliest_game_ts')
|
||||||
if isinstance(earliest_ts, int):
|
if isinstance(earliest_ts, int):
|
||||||
earliest = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
|
earliest = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
|
||||||
header += f"\n\n\nStart of these 1000 games: {earliest}"
|
header += f"\n\n\nStart of these 1000 games: {earliest}"
|
||||||
else:
|
else:
|
||||||
header = f"📈 {username}: last year (rated), games: {games_count}"
|
header = f"📈 {escaped_username}: last year (rated), games: {games_count}"
|
||||||
# Use earliest actual game date instead of naive 'year ago'
|
# Use earliest actual game date instead of naive 'year ago'
|
||||||
earliest_ts = data.get('earliest_game_ts', period_start)
|
earliest_ts = data.get('earliest_game_ts', period_start)
|
||||||
if isinstance(earliest_ts, int) and isinstance(period_end, int):
|
if isinstance(earliest_ts, int) and isinstance(period_end, int):
|
||||||
|
|
@ -233,9 +377,10 @@ class StatsFormatter:
|
||||||
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
||||||
rating = mode_stats.get('rating')
|
rating = mode_stats.get('rating')
|
||||||
rating_str = rating if rating is not None else "—"
|
rating_str = rating if rating is not None else "—"
|
||||||
lines.append(
|
line = f"{emoji} {mode.title()}: {games_played} Δ {rating_change_str} R {rating_str} ✅ {wins} ❌ {losses} 🤝 {draws}"
|
||||||
f"{emoji} {mode.title()}: {games_played} Δ {rating_change_str} R {rating_str} ✅ {wins} ❌ {losses} 🤝 {draws}"
|
if mode in ("blitz", "rapid", "classical"):
|
||||||
)
|
line += f" 🎯 {StatsFormatter._format_accuracy(mode_stats.get('accuracy'))}"
|
||||||
|
lines.append(line)
|
||||||
# Join lines with newlines between each mode
|
# Join lines with newlines between each mode
|
||||||
# Between regular modes: one empty line (\n\n)
|
# Between regular modes: one empty line (\n\n)
|
||||||
# Before last mode: two empty lines (\n\n\n)
|
# Before last mode: two empty lines (\n\n\n)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,9 @@ TRANSLATIONS = {
|
||||||
'stats_title': "📊 Statistics {username} • {date_range}\n\n",
|
'stats_title': "📊 Statistics {username} • {date_range}\n\n",
|
||||||
'puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {unsolved})\n\n",
|
'puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {unsolved})\n\n",
|
||||||
'games_section': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
|
'games_section': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
|
||||||
|
'games_section_with_accuracy': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n🎯 Accuracy: {accuracy}\n\n",
|
||||||
|
'game_rows_heading': "{emoji} {game_type} games:\n",
|
||||||
|
|
||||||
# Set period
|
# Set period
|
||||||
'select_gamer_for_period': "⏱️ <b>Select player to set notification period:</b>\n\n",
|
'select_gamer_for_period': "⏱️ <b>Select player to set notification period:</b>\n\n",
|
||||||
'select_period': "⏱️ Select period for player {username}:\n📱 Notifications will be sent to personal messages",
|
'select_period': "⏱️ Select period for player {username}:\n📱 Notifications will be sent to personal messages",
|
||||||
|
|
@ -110,7 +112,7 @@ TRANSLATIONS = {
|
||||||
'period_puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {failed})\n\n",
|
'period_puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {failed})\n\n",
|
||||||
'period_games_section': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
|
'period_games_section': "{emoji} {game_type} — {games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
|
||||||
'no_activity': "📭 No activity for this period",
|
'no_activity': "📭 No activity for this period",
|
||||||
|
|
||||||
# Last year or 1000 games
|
# Last year or 1000 games
|
||||||
'last_year_1000_processing': "⏳ Processing request... This may take a while as requests are very slow.",
|
'last_year_1000_processing': "⏳ Processing request... This may take a while as requests are very slow.",
|
||||||
'last_year_1000_player_processing': "🔄 Requesting data for player <b>{username}</b>...",
|
'last_year_1000_player_processing': "🔄 Requesting data for player <b>{username}</b>...",
|
||||||
|
|
@ -228,7 +230,9 @@ TRANSLATIONS = {
|
||||||
'stats_title': "📊 Статистика {username} • {date_range}\n\n",
|
'stats_title': "📊 Статистика {username} • {date_range}\n\n",
|
||||||
'puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {unsolved})\n\n",
|
'puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {unsolved})\n\n",
|
||||||
'games_section': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
|
'games_section': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
|
||||||
|
'games_section_with_accuracy': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n🎯 Точность: {accuracy}\n\n",
|
||||||
|
'game_rows_heading': "{emoji} Партии {game_type}:\n",
|
||||||
|
|
||||||
# Set period
|
# Set period
|
||||||
'select_gamer_for_period': "⏱️ <b>Выберите игрока для установки периода уведомлений:</b>\n\n",
|
'select_gamer_for_period': "⏱️ <b>Выберите игрока для установки периода уведомлений:</b>\n\n",
|
||||||
'select_period': "⏱️ Выберите период для игрока {username}:\n📱 Уведомления будут отправляться в личные сообщения",
|
'select_period': "⏱️ Выберите период для игрока {username}:\n📱 Уведомления будут отправляться в личные сообщения",
|
||||||
|
|
@ -245,7 +249,7 @@ TRANSLATIONS = {
|
||||||
'period_puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {failed})\n\n",
|
'period_puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {failed})\n\n",
|
||||||
'period_games_section': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
|
'period_games_section': "{emoji} {game_type} — {games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
|
||||||
'no_activity': "📭 Нет активности за этот период",
|
'no_activity': "📭 Нет активности за этот период",
|
||||||
|
|
||||||
# Last year or 1000 games
|
# Last year or 1000 games
|
||||||
'last_year_1000_processing': "⏳ Обработка запроса... Это может занять некоторое время, так как запросы очень медленные.",
|
'last_year_1000_processing': "⏳ Обработка запроса... Это может занять некоторое время, так как запросы очень медленные.",
|
||||||
'last_year_1000_player_processing': "🔄 Запрос данных для игрока <b>{username}</b>...",
|
'last_year_1000_player_processing': "🔄 Запрос данных для игрока <b>{username}</b>...",
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ class LichessAPI:
|
||||||
logger.error(f"Error getting week stats: {e}")
|
logger.error(f"Error getting week stats: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_games_period(self, username: str, since: int, until: int, rated_only: Optional[bool] = None) -> Optional[Dict[str, Any]]:
|
async def get_games_period(self, username: str, since: int, until: int, rated_only: Optional[bool] = None, include_games: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
"""Get games for a specific period"""
|
"""Get games for a specific period"""
|
||||||
await self.rate_limiter.wait_if_needed()
|
await self.rate_limiter.wait_if_needed()
|
||||||
try:
|
try:
|
||||||
|
|
@ -106,6 +106,8 @@ class LichessAPI:
|
||||||
params = {"since": since, "until": until}
|
params = {"since": since, "until": until}
|
||||||
if rated_only is not None:
|
if rated_only is not None:
|
||||||
params["rated_only"] = "true" if rated_only else "false"
|
params["rated_only"] = "true" if rated_only else "false"
|
||||||
|
if include_games:
|
||||||
|
params["include_games"] = "true"
|
||||||
logger.info(f"🔍 LichessAPI.get_games_period: URL={url}, params={params}")
|
logger.info(f"🔍 LichessAPI.get_games_period: URL={url}, params={params}")
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,8 @@ class LichessClient:
|
||||||
params = {
|
params = {
|
||||||
'since': since_ms, # Начало периода
|
'since': since_ms, # Начало периода
|
||||||
'until': until_ms, # Конец периода
|
'until': until_ms, # Конец периода
|
||||||
'max': 1000 # Максимум игр за запрос (лимит Lichess API)
|
'max': 1000, # Максимум игр за запрос (лимит Lichess API)
|
||||||
|
'accuracy': 'true' # Включить players.<color>.analysis.accuracy для проанализированных партий
|
||||||
}
|
}
|
||||||
|
|
||||||
# Заголовки для получения NDJSON формата
|
# Заголовки для получения NDJSON формата
|
||||||
|
|
|
||||||
|
|
@ -498,9 +498,12 @@ async def get_games_of_period(
|
||||||
until: int = Query(...,
|
until: int = Query(...,
|
||||||
description="Конец периода (Unix timestamp в миллисекундах)",
|
description="Конец периода (Unix timestamp в миллисекундах)",
|
||||||
example=1641081600000),
|
example=1641081600000),
|
||||||
rated_only: bool = Query(True,
|
rated_only: bool = Query(True,
|
||||||
description="Только рейтинговые игры (по умолчанию true - рекомендуется)",
|
description="Только рейтинговые игры (по умолчанию true - рекомендуется)",
|
||||||
example=True)
|
example=True),
|
||||||
|
include_games: bool = Query(False,
|
||||||
|
description="Включить построчный список отдельных партий (только blitz/rapid/classical)",
|
||||||
|
example=False)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
## Статистика игр за период
|
## Статистика игр за период
|
||||||
|
|
@ -554,7 +557,7 @@ async def get_games_of_period(
|
||||||
# Конвертируем миллисекунды в секунды для внутренней логики
|
# Конвертируем миллисекунды в секунды для внутренней логики
|
||||||
since_seconds = since // 1000
|
since_seconds = since // 1000
|
||||||
until_seconds = until // 1000
|
until_seconds = until // 1000
|
||||||
result = await stats_service.get_games_of_period(username, since_seconds, until_seconds, rated_only)
|
result = await stats_service.get_games_of_period(username, since_seconds, until_seconds, rated_only, include_games)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
# Реальная ошибка при обращении к Lichess — не маскируем её под "0 игр"
|
# Реальная ошибка при обращении к Lichess — не маскируем её под "0 игр"
|
||||||
raise HTTPException(status_code=502, detail=result.message)
|
raise HTTPException(status_code=502, detail=result.message)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Lichess Statistics API - Модели данных
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# МОДЕЛИ СТАТИСТИКИ ЗАДАЧ (ПАЗЛОВ)
|
# МОДЕЛИ СТАТИСТИКИ ЗАДАЧ (ПАЗЛОВ)
|
||||||
|
|
@ -125,6 +125,7 @@ class GamePlayer(BaseModel):
|
||||||
user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе")
|
user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе")
|
||||||
rating: Optional[int] = Field(None, description="Рейтинг игрока")
|
rating: Optional[int] = Field(None, description="Рейтинг игрока")
|
||||||
ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга")
|
ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга")
|
||||||
|
analysis: Optional[Dict[str, Any]] = Field(None, description="Пост-анализ партии (только если игрок запускал анализ на Lichess), содержит поле 'accuracy'")
|
||||||
|
|
||||||
class Game(BaseModel):
|
class Game(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
@ -147,6 +148,23 @@ class Game(BaseModel):
|
||||||
winner: Optional[str] = Field(None, description="Победитель (white, black или null)")
|
winner: Optional[str] = Field(None, description="Победитель (white, black или null)")
|
||||||
moves: str = Field(..., description="Ходы игры в PGN формате")
|
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 — не удалось определить сторону")
|
||||||
|
tracked_rating_diff: Optional[int] = Field(None, description="Изменение рейтинга отслеживаемого пользователя по итогам этой партии")
|
||||||
|
|
||||||
class GameStats(BaseModel):
|
class GameStats(BaseModel):
|
||||||
"""
|
"""
|
||||||
Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.).
|
Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.).
|
||||||
|
|
@ -163,6 +181,8 @@ class GameStats(BaseModel):
|
||||||
draws: int = Field(..., description="Количество ничьих", example=1)
|
draws: int = Field(..., description="Количество ничьих", example=1)
|
||||||
rating_change: int = Field(..., description="Общее изменение рейтинга", example=15)
|
rating_change: int = Field(..., description="Общее изменение рейтинга", example=15)
|
||||||
rating: Optional[int] = Field(None, description="Итоговый рейтинг после последней игры (только если games_played > 0)", example=2850)
|
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):
|
class GamesOfPeriodStats(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Lichess Statistics API - Сервис обработки статистики
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from lichess_client import LichessClient
|
from lichess_client import LichessClient
|
||||||
from models import UserStats, TaskStats, GameModeStats, GamesStats, ActivityResponse, GameStats, GamesOfPeriodStats, GamesOfPeriodResponse, PuzzleStats, PuzzleOfPeriodResponse
|
from models import UserStats, TaskStats, GameModeStats, GamesStats, ActivityResponse, GameStats, GameRow, GamesOfPeriodStats, GamesOfPeriodResponse, PuzzleStats, PuzzleOfPeriodResponse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Настройка логирования для модуля
|
# Настройка логирования для модуля
|
||||||
|
|
@ -536,17 +536,83 @@ class StatsService:
|
||||||
final_rating = rating_before + rating_diff
|
final_rating = rating_before + rating_diff
|
||||||
|
|
||||||
return rating_diff, final_rating
|
return rating_diff, final_rating
|
||||||
|
|
||||||
def _process_games_of_period(self, games: List[Dict[str, Any]], username: str) -> GamesOfPeriodStats:
|
def _get_accuracy(self, game: Dict[str, Any], username: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Получает точность (accuracy) отслеживаемого пользователя в партии,
|
||||||
|
если партия была проанализирована на Lichess. Иначе None.
|
||||||
|
"""
|
||||||
|
players = game.get('players', {})
|
||||||
|
username_lower = username.lower()
|
||||||
|
white_user = players.get('white', {}).get('user', {}) or {}
|
||||||
|
black_user = players.get('black', {}).get('user', {}) or {}
|
||||||
|
|
||||||
|
if white_user.get('name', '').lower() == username_lower:
|
||||||
|
user_color = 'white'
|
||||||
|
elif black_user.get('name', '').lower() == username_lower:
|
||||||
|
user_color = 'black'
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
analysis = players.get(user_color, {}).get('analysis') or {}
|
||||||
|
return analysis.get('accuracy')
|
||||||
|
|
||||||
|
def _build_game_row(self, game: Dict[str, Any], username: str) -> GameRow:
|
||||||
|
"""
|
||||||
|
Строит построчное представление одной партии (белые/чёрные в порядке партии).
|
||||||
|
"""
|
||||||
|
players = game.get('players', {})
|
||||||
|
white = players.get('white', {}) or {}
|
||||||
|
black = players.get('black', {}) or {}
|
||||||
|
winner = game.get('winner')
|
||||||
|
|
||||||
|
if winner == 'white':
|
||||||
|
result = "1-0"
|
||||||
|
elif winner == 'black':
|
||||||
|
result = "0-1"
|
||||||
|
else:
|
||||||
|
result = "1/2-1/2"
|
||||||
|
|
||||||
|
username_lower = username.lower()
|
||||||
|
white_name = (white.get('user') or {}).get('name', '?')
|
||||||
|
black_name = (black.get('user') or {}).get('name', '?')
|
||||||
|
if white_name.lower() == username_lower:
|
||||||
|
tracked_is_white = True
|
||||||
|
elif black_name.lower() == username_lower:
|
||||||
|
tracked_is_white = False
|
||||||
|
else:
|
||||||
|
tracked_is_white = None
|
||||||
|
|
||||||
|
if tracked_is_white is True:
|
||||||
|
tracked_rating_diff = white.get('ratingDiff')
|
||||||
|
elif tracked_is_white is False:
|
||||||
|
tracked_rating_diff = black.get('ratingDiff')
|
||||||
|
else:
|
||||||
|
tracked_rating_diff = None
|
||||||
|
|
||||||
|
return GameRow(
|
||||||
|
white_name=white_name,
|
||||||
|
black_name=black_name,
|
||||||
|
white_rating=white.get('rating'),
|
||||||
|
black_rating=black.get('rating'),
|
||||||
|
white_accuracy=(white.get('analysis') or {}).get('accuracy'),
|
||||||
|
black_accuracy=(black.get('analysis') or {}).get('accuracy'),
|
||||||
|
result=result,
|
||||||
|
created_at=game.get('createdAt', 0),
|
||||||
|
tracked_is_white=tracked_is_white,
|
||||||
|
tracked_rating_diff=tracked_rating_diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_games_of_period(self, games: List[Dict[str, Any]], username: str, include_games: bool = False) -> GamesOfPeriodStats:
|
||||||
"""
|
"""
|
||||||
Обрабатывает игры за период и возвращает статистику
|
Обрабатывает игры за период и возвращает статистику
|
||||||
"""
|
"""
|
||||||
# Инициализируем статистику для всех типов игр
|
# Инициализируем статистику для всех типов игр
|
||||||
stats = {
|
stats = {
|
||||||
'bullet': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
'bullet': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||||
'blitz': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
'blitz': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None, 'accuracy_sum': 0.0, 'accuracy_count': 0, 'games': []},
|
||||||
'rapid': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
'rapid': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None, 'accuracy_sum': 0.0, 'accuracy_count': 0, 'games': []},
|
||||||
'classical': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
'classical': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None, 'accuracy_sum': 0.0, 'accuracy_count': 0, 'games': []},
|
||||||
'correspondence': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
'correspondence': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||||
'total': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}
|
'total': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}
|
||||||
}
|
}
|
||||||
|
|
@ -593,7 +659,16 @@ class StatsService:
|
||||||
# Сохраняем итоговый рейтинг после последней игры
|
# Сохраняем итоговый рейтинг после последней игры
|
||||||
if final_rating is not None:
|
if final_rating is not None:
|
||||||
stats[speed]['rating'] = final_rating
|
stats[speed]['rating'] = final_rating
|
||||||
|
|
||||||
|
# Точность и построчный разбор — только для blitz/rapid/classical
|
||||||
|
if speed in ('blitz', 'rapid', 'classical'):
|
||||||
|
accuracy = self._get_accuracy(game, username)
|
||||||
|
if accuracy is not None:
|
||||||
|
stats[speed]['accuracy_sum'] += accuracy
|
||||||
|
stats[speed]['accuracy_count'] += 1
|
||||||
|
if include_games:
|
||||||
|
stats[speed]['games'].append(self._build_game_row(game, username))
|
||||||
|
|
||||||
# Обновляем общую статистику
|
# Обновляем общую статистику
|
||||||
stats['total']['games_played'] += 1
|
stats['total']['games_played'] += 1
|
||||||
if result == 'win':
|
if result == 'win':
|
||||||
|
|
@ -609,14 +684,18 @@ class StatsService:
|
||||||
|
|
||||||
# Создаем объекты GameStats, устанавливая rating только для режимов с играми
|
# Создаем объекты GameStats, устанавливая rating только для режимов с играми
|
||||||
def create_game_stats(mode_stats):
|
def create_game_stats(mode_stats):
|
||||||
|
mode_stats = mode_stats.copy()
|
||||||
|
# accuracy_sum/accuracy_count/games присутствуют только у blitz/rapid/classical
|
||||||
|
accuracy_sum = mode_stats.pop('accuracy_sum', 0.0)
|
||||||
|
accuracy_count = mode_stats.pop('accuracy_count', 0)
|
||||||
|
games_list = mode_stats.pop('games', [])
|
||||||
|
mode_stats['accuracy'] = round(accuracy_sum / accuracy_count, 1) if accuracy_count > 0 else None
|
||||||
|
if include_games and games_list:
|
||||||
|
mode_stats['games'] = sorted(games_list, key=lambda r: r.created_at)
|
||||||
# Устанавливаем rating только если были игры
|
# Устанавливаем rating только если были игры
|
||||||
if mode_stats['games_played'] > 0 and mode_stats['rating'] is not None:
|
if not (mode_stats['games_played'] > 0 and mode_stats['rating'] is not None):
|
||||||
return GameStats(**mode_stats)
|
mode_stats['rating'] = None
|
||||||
else:
|
return GameStats(**mode_stats)
|
||||||
# Убираем rating для режимов без игр
|
|
||||||
mode_stats_copy = mode_stats.copy()
|
|
||||||
mode_stats_copy['rating'] = None
|
|
||||||
return GameStats(**mode_stats_copy)
|
|
||||||
|
|
||||||
return GamesOfPeriodStats(
|
return GamesOfPeriodStats(
|
||||||
bullet=create_game_stats(stats['bullet']),
|
bullet=create_game_stats(stats['bullet']),
|
||||||
|
|
@ -627,19 +706,20 @@ class StatsService:
|
||||||
total=create_game_stats(stats['total'])
|
total=create_game_stats(stats['total'])
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_games_of_period(self, username: str, since_timestamp: int, until_timestamp: int, rated_only: bool = True) -> GamesOfPeriodResponse:
|
async def get_games_of_period(self, username: str, since_timestamp: int, until_timestamp: int, rated_only: bool = True, include_games: bool = False) -> GamesOfPeriodResponse:
|
||||||
"""
|
"""
|
||||||
Получает статистику игр пользователя за определенный период.
|
Получает статистику игр пользователя за определенный период.
|
||||||
|
|
||||||
Получает игры от Lichess API за указанный период, обрабатывает их
|
Получает игры от Lichess API за указанный период, обрабатывает их
|
||||||
и возвращает агрегированную статистику по режимам игр.
|
и возвращает агрегированную статистику по режимам игр.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Имя пользователя на Lichess
|
username: Имя пользователя на Lichess
|
||||||
since_timestamp: Начало периода (Unix timestamp в секундах)
|
since_timestamp: Начало периода (Unix timestamp в секундах)
|
||||||
until_timestamp: Конец периода (Unix timestamp в секундах)
|
until_timestamp: Конец периода (Unix timestamp в секундах)
|
||||||
rated_only: Только рейтинговые игры (по умолчанию True)
|
rated_only: Только рейтинговые игры (по умолчанию True)
|
||||||
|
include_games: Включить построчный список отдельных партий (только blitz/rapid/classical)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
GamesOfPeriodResponse с статистикой игр
|
GamesOfPeriodResponse с статистикой игр
|
||||||
"""
|
"""
|
||||||
|
|
@ -674,7 +754,7 @@ class StatsService:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обрабатываем игры
|
# Обрабатываем игры
|
||||||
games_stats = self._process_games_of_period(games, username)
|
games_stats = self._process_games_of_period(games, username, include_games=include_games)
|
||||||
# Определяем время самой старой партии (в секундах)
|
# Определяем время самой старой партии (в секундах)
|
||||||
earliest_game_ts = None
|
earliest_game_ts = None
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue