Add annual ranking snapshot API

This commit is contained in:
devRaGonSa
2026-06-08 14:27:01 +02:00
parent 4c4de2b053
commit c8ba141a4f
4 changed files with 247 additions and 27 deletions

View File

@@ -49,6 +49,7 @@ from .historical_storage import (
list_weekly_top_kills,
)
from .rcon_historical_read_model import get_rcon_historical_match_detail
from .rcon_annual_rankings import get_annual_ranking_snapshot
from .rcon_historical_player_stats import search_rcon_materialized_players
from .rcon_historical_player_stats import get_rcon_materialized_player_stats
from .normalizers import normalize_map_name
@@ -661,6 +662,53 @@ def build_stats_player_profile_payload(
}
def build_annual_ranking_snapshot_payload(
*,
year: int,
server_id: str | None = None,
metric: str = "kills",
limit: int = 20,
) -> dict[str, object]:
"""Return an annual ranking payload from precomputed snapshots."""
result = get_annual_ranking_snapshot(
year=year,
server_key=server_id,
metric=metric,
limit=limit,
)
items = result.get("items") or []
return {
"status": "ok",
"data": {
"year": result.get("year"),
"server_id": result.get("server_id"),
"metric": result.get("metric"),
"limit": result.get("limit"),
"source": result.get("source"),
"snapshot_status": result.get("snapshot_status"),
"generated_at": result.get("generated_at"),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"source_matches_count": int(result.get("source_matches_count") or 0),
"items": [
{
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0),
"matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0),
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
}
for item in items
if isinstance(item, dict)
],
},
}
def build_weekly_leaderboard_payload(
*,
limit: int = 10,

View File

@@ -1,16 +1,17 @@
"""Annual ranking snapshot generator over materialized RCON match stats."""
"""Annual ranking snapshot generator and reader over materialized RCON match stats."""
from __future__ import annotations
import argparse
import json
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .sqlite_utils import connect_sqlite_writer
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
def generate_annual_ranking_snapshot(
@@ -73,7 +74,7 @@ def generate_annual_ranking_snapshot(
scope_params=scope_params,
)
snapshot_id = _delete_existing_snapshot(
_delete_existing_snapshot(
connection=connection,
year=year,
server_key=normalized_server_key,
@@ -109,6 +110,75 @@ def generate_annual_ranking_snapshot(
}
def get_annual_ranking_snapshot(
*,
year: int,
server_key: str | None = None,
metric: str = "kills",
limit: int = 20,
db_path: Path | None = None,
) -> dict[str, object]:
"""Load one annual ranking snapshot without recalculating the ranking."""
normalized_year = int(year)
if normalized_year < 1:
raise ValueError("year must be greater than zero")
normalized_server_key = _normalize_server_key(server_key)
normalized_metric = _normalize_metric(metric)
normalized_limit = max(1, int(limit))
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(connect_sqlite_readonly(resolved_path))
with connection_scope as connection:
snapshot = _find_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if snapshot is None:
return {
"snapshot_status": "missing",
"year": normalized_year,
"server_id": normalized_server_key,
"metric": normalized_metric,
"limit": normalized_limit,
"source": "rcon-annual-ranking-snapshot",
"generated_at": None,
"window_start": None,
"window_end": None,
"source_matches_count": 0,
"items": [],
}
effective_limit = min(normalized_limit, int(snapshot.get("limit_size") or normalized_limit))
items = _list_items(
connection=connection,
snapshot_id=int(snapshot["id"]),
limit=effective_limit,
)
return {
"snapshot_status": "ready",
"year": normalized_year,
"server_id": normalized_server_key,
"metric": normalized_metric,
"limit": effective_limit,
"source": "rcon-annual-ranking-snapshot",
"generated_at": snapshot.get("generated_at"),
"window_start": snapshot.get("window_start"),
"window_end": snapshot.get("window_end"),
"source_matches_count": int(snapshot.get("source_matches_count") or 0),
"items": items,
}
def _normalize_server_key(server_key: str | None) -> str:
normalized = str(server_key or "").strip()
normalized_lower = normalized.lower()
@@ -120,7 +190,7 @@ def _normalize_server_key(server_key: str | None) -> str:
def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower()
if normalized != "kills":
raise ValueError("Only metric 'kills' is supported for annual ranking generation.")
raise ValueError("Only metric 'kills' is supported for annual ranking endpoints.")
return normalized
@@ -141,6 +211,9 @@ def _fetch_annual_ranking_rows(
scope_params: list[object],
) -> list[dict[str, object]]:
# For now metric support is intentionally narrowed to kills only.
if metric != "kills":
return []
rows = connection.execute(
f"""
SELECT
@@ -167,8 +240,6 @@ def _fetch_annual_ranking_rows(
""",
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
).fetchall()
if metric != "kills":
return []
return [dict(row) for row in rows]
@@ -198,6 +269,36 @@ def _count_matches_in_window(
return int(row["source_matches_count"] or 0) if row else 0
def _find_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> dict[str, object] | None:
row = connection.execute(
"""
SELECT
id,
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
status,
source_matches_count,
generated_at
FROM rcon_annual_ranking_snapshots
WHERE year = ? AND server_key = ? AND metric = ?
LIMIT 1
""",
[year, server_key, metric],
).fetchone()
return dict(row) if row else None
def _find_existing_snapshot(
*,
connection: object,
@@ -361,9 +462,9 @@ def _get_snapshot(*, connection: object, snapshot_id: int) -> dict[str, object]
return dict(row)
def _list_items(*, connection: object, snapshot_id: int) -> list[dict[str, object]]:
rows = connection.execute(
"""
def _list_items(*, connection: object, snapshot_id: int, limit: int | None = None) -> list[dict[str, object]]:
params: list[object] = [snapshot_id]
query = """
SELECT
ranking_position,
player_id,
@@ -377,16 +478,21 @@ def _list_items(*, connection: object, snapshot_id: int) -> list[dict[str, objec
FROM rcon_annual_ranking_snapshot_items
WHERE snapshot_id = ?
ORDER BY ranking_position ASC
""",
[snapshot_id],
).fetchall()
"""
if limit is not None:
query = f"{query}\n LIMIT ?"
params.append(limit)
rows = connection.execute(query, params).fetchall()
return [dict(row) for row in rows]
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
if server_key == ALL_SERVERS_SLUG:
return "", []
return f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)", [server_key, server_key]
return (
f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)",
[server_key, server_key],
)
def _main(argv: list[str] | None = None) -> int:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from http import HTTPStatus
from datetime import datetime, timezone
from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind
@@ -17,6 +18,7 @@ from .payloads import (
build_elo_mmr_player_payload,
build_error_payload,
build_health_payload,
build_annual_ranking_snapshot_payload,
build_historical_leaderboard_payload,
build_historical_match_detail_payload,
build_monthly_mvp_payload,
@@ -85,6 +87,27 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
limit=limit,
)
if parsed.path == "/api/stats/rankings/annual":
params = parse_qs(parsed.query)
metric = params.get("metric", ["kills"])[0]
if metric != "kills":
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
year = _parse_year(parsed.query)
if year is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid year parameter")
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_annual_ranking_snapshot_payload(
year=year,
server_id=server_id,
metric=metric,
limit=limit,
)
if parsed.path == "/api/current-match":
server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug:
@@ -431,6 +454,20 @@ def _parse_limit(query: str) -> int | None:
return limit
def _parse_year(query: str) -> int | None:
params = parse_qs(query)
raw_year = params.get("year", [None])[0]
if raw_year is None:
return datetime.now(timezone.utc).year
try:
year = int(raw_year)
except ValueError:
return None
if year <= 0:
return None
return year
def _parse_limit_with_default(query: str, default: int = 20) -> int | None:
params = parse_qs(query)
if "limit" not in params: