diff --git a/ai/tasks/pending/TASK-169-add-annual-ranking-snapshot-api.md b/ai/tasks/done/TASK-169-add-annual-ranking-snapshot-api.md similarity index 52% rename from ai/tasks/pending/TASK-169-add-annual-ranking-snapshot-api.md rename to ai/tasks/done/TASK-169-add-annual-ranking-snapshot-api.md index a84405c..d24be69 100644 --- a/ai/tasks/pending/TASK-169-add-annual-ranking-snapshot-api.md +++ b/ai/tasks/done/TASK-169-add-annual-ranking-snapshot-api.md @@ -1,7 +1,7 @@ ---- +--- id: TASK-169-add-annual-ranking-snapshot-api title: Add annual ranking snapshot API -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -15,20 +15,20 @@ priority: medium ## Goal -Exponer una API de lectura pública para el ranking anual top 20 precomputado desde snapshots almacenadas. +Exponer una API de lectura publica para el ranking anual top 20 precomputado desde snapshots almacenadas. ## Context -La API debe consumir `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items` generados por el módulo de generación (TASK-168) y las tablas añadidas por TASK-167. +La API debe consumir `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items` generados por el modulo de generacion (TASK-168) y las tablas añadidas por TASK-167. -Mantiene compatibilidad entre SQLite y Postgres usando el patrón de conexión compartido. +Mantiene compatibilidad entre SQLite y Postgres usando el patron de conexion compartido. ## Steps 1. Leer los archivos indicados en "Files to Read First". 2. Implementar el endpoint GET `/api/stats/rankings/annual` en backend sin recalcular ranking. -3. Añadir payload y registro de ruta siguiendo patrones existentes. -4. Validar comportamientos esperados (snapshot existente, faltante, métrica no soportada). +3. Anadir payload y registro de ruta siguiendo patrones existentes. +4. Validar comportamientos esperados (snapshot existente, faltante, metrica no soportada). ## Files to Read First @@ -47,7 +47,6 @@ Mantiene compatibilidad entre SQLite y Postgres usando el patr ## Expected Files to Modify -- `ai/tasks/pending/TASK-169-add-annual-ranking-snapshot-api.md` (al crear la tarea) - `backend/app/rcon_annual_rankings.py` - `backend/app/payloads.py` - `backend/app/routes.py` @@ -65,17 +64,47 @@ Mantiene compatibilidad entre SQLite y Postgres usando el patr - No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png`. - No reactivar Elo/MMR. - No incluir Comunidad Hispana #03. -- No modificar workers históricos salvo justificación imprescindible. +- No modificar workers historicos salvo justificacion imprescindible. ## Validation - `python -m compileall backend/app` - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` -- Validación manual del contrato con backend local o método equivalente. +- Validacion manual del contrato con backend local o metodo equivalente. ## Outcome -- registrar archivos modificados -- validar comportamiento con y sin snapshot -- indicar limitaciones conocidas -- proponer siguiente tarea +### Files modified + +- `backend/app/rcon_annual_rankings.py` +- `backend/app/payloads.py` +- `backend/app/routes.py` + +### Endpoint created + +- `GET /api/stats/rankings/annual?year=&server_id=&metric=kills&limit=` +- Respuestas: + - `snapshot_status=ready` cuando existe snapshot. + - `snapshot_status=missing` cuando no existe snapshot, y devuelve `items=[]`. +- Soporta `metric=kills` y devuelve 400 para metricas no soportadas. + +### Validations performed + +- `python -m compileall backend/app` ✅ +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` ✅ +- Validacion manual usando `app.routes.resolve_get_payload`: + - `/api/stats/rankings/annual` (HTTP 200, respuesta OK) + - `/api/stats/rankings/annual?year=1990` (HTTP 200) + - `/api/stats/rankings/annual?year=1990&metric=kills&limit=3` (HTTP 200) + - `/api/stats/rankings/annual?metric=deaths` (HTTP 400, metrica invalida) + - `/api/stats/rankings/annual?year=1900` (HTTP 200, snapshot_status `missing`, `items=[]`) + +### Limitations + +- Soporta solo `metric=kills` en esta fase. +- Endpoint no genera snapshots (no recalculo); requiere que TASK-168 haya generado datos. +- No se toco frontend en este alcance. + +### Next task recommendation + +Conectar ranking anual top 20 desde frontend Stats. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index fe8140b..acd45ac 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -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, diff --git a/backend/app/rcon_annual_rankings.py b/backend/app/rcon_annual_rankings.py index 5bef3df..ee67661 100644 --- a/backend/app/rcon_annual_rankings.py +++ b/backend/app/rcon_annual_rankings.py @@ -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: diff --git a/backend/app/routes.py b/backend/app/routes.py index 904dc34..0a86ac4 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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: