Fix annual ranking generator PostgreSQL default

This commit is contained in:
devRaGonSa
2026-06-09 13:50:18 +02:00
parent b64d4e180f
commit 5355f84c5d
4 changed files with 427 additions and 61 deletions

View File

@@ -0,0 +1,185 @@
---
id: TASK-205
title: Fix annual ranking generator PostgreSQL default
status: done
type: backend
team: Backend Senior
supporting_teams:
- Arquitecto de Base de Datos
- Arquitecto Python
roadmap_item: foundation
priority: high
---
# TASK-205 - Fix annual ranking generator PostgreSQL default
## Goal
Corregir el CLI anual de `backend/app/rcon_annual_rankings.py` para que use PostgreSQL por defecto cuando `HLL_BACKEND_DATABASE_URL` esta configurado, alineando la generacion operativa con el read path de `/api/ranking?timeframe=annual...`.
## Context
Produccion ya confirma que la API anual consulta el read model correcto pero no encuentra snapshot, mientras el comando manual anual generaba en SQLite local. La causa raiz validada era que `_main()` llamaba a `generate_annual_ranking_snapshot(..., db_path=get_storage_path())`, lo que forzaba `explicit_sqlite_path != None` y desactivaba `use_postgres_rcon_storage(...)` aunque `get_database_url()` existiera.
Este comportamiento ya se habia corregido en weekly/monthly durante `TASK-195`, por lo que esta task replica el mismo criterio operacional en el flujo anual sin tocar frontend, endpoints publicos ni la logica anual fuera de la seleccion de storage por defecto.
Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution.
## Steps
1. Revisar el flujo anual actual y comparar `_main()` con el patron corregido en weekly/monthly.
2. Cambiar el CLI anual para que use `db_path=None` por defecto y PostgreSQL cuando el entorno lo soporte.
3. Mantener SQLite solo como override explicito mediante `--sqlite-path`.
4. Preservar la salida JSON del CLI y el contrato publico del endpoint anual.
5. Extender la regresion de `scripts/run-stats-validation.ps1` para cubrir la ruta anual.
6. Documentar causa raiz, comportamiento anterior, comportamiento nuevo y validacion operacional.
## Files to Read First
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `backend/app/rcon_annual_rankings.py`
- `backend/app/config.py`
- `backend/app/postgres_rcon_storage.py`
- `backend/app/rcon_historical_leaderboards.py`
- `scripts/run-stats-validation.ps1`
- `docs/ranking-snapshot-read-model-plan.md`
- `docs/annual-ranking-snapshot-runbook.md`
- `docs/annual-ranking-snapshot-schema-plan.md`
- `ai/tasks/done/TASK-195-fix-ranking-snapshot-generator-postgres-default.md`
- `ai/tasks/done/TASK-196-fix-ranking-snapshot-cli-json-serialization.md`
## Expected Files to Modify
- `backend/app/rcon_annual_rankings.py`
- `scripts/run-stats-validation.ps1`
- `docs/annual-ranking-snapshot-runbook.md`
- `ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md`
## Constraints
- Keep the change minimal.
- No ejecutar `ai-platform run`.
- No modificar frontend ni diseno.
- No cambiar el contrato publico de `/api/ranking?timeframe=annual...`.
- No cambiar la logica de calculo anual salvo lo necesario para usar PostgreSQL por defecto.
- No tocar weekly/monthly salvo reutilizacion minima de patron o helper ya existente.
- No tocar `player_search_index`, `player_period_stats` ni el runner.
- No tocar `frontend/assets/img/weapons/` ni SVGs de armas.
- No reactivar Elo/MMR.
- No reintroducir Comunidad Hispana #03.
- No mezclar esta task con los cambios visuales pendientes de `TASK-204`.
## Validation
Before completing the task ensure:
- `python -m py_compile backend/app/rcon_annual_rankings.py`
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- validacion local o por import demuestra que `_main()` ya no pasa `get_storage_path()` por defecto
- validacion local demuestra que `generate_annual_ranking_snapshot(..., db_path=None)` usa PostgreSQL cuando `HLL_BACKEND_DATABASE_URL` esta configurado
- la salida JSON del CLI anual sigue serializando correctamente
- `git diff --name-only` matches the expected scope
## Outcome
Causa raiz:
- `backend/app/rcon_annual_rankings.py` llamaba a `generate_annual_ranking_snapshot(..., db_path=get_storage_path())` desde `_main()`
- eso forzaba `explicit_sqlite_path != None`
- `use_postgres_rcon_storage(...)` devolvia `False` incluso con `HLL_BACKEND_DATABASE_URL` configurado
- el CLI anual generaba snapshots en SQLite mientras `/api/ranking?timeframe=annual...` leia PostgreSQL
Comportamiento anterior:
- `python -m app.rcon_annual_rankings generate ...`
- usaba SQLite por defecto
- podia dejar `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items` vacias en PostgreSQL aunque el operador hubiera ejecutado el comando con exito
Comportamiento nuevo:
- el CLI anual usa PostgreSQL por defecto cuando `HLL_BACKEND_DATABASE_URL` esta configurado
- SQLite queda disponible solo mediante `--sqlite-path`
- la salida JSON del CLI anual sigue serializando `datetime` y `date` correctamente
Archivos modificados:
- `backend/app/rcon_annual_rankings.py`
- `scripts/run-stats-validation.ps1`
- `docs/annual-ranking-snapshot-runbook.md`
- `ai/tasks/done/TASK-205-fix-annual-ranking-generator-postgres-default.md`
Cambio aplicado en codigo:
- `backend/app/rcon_annual_rankings.py`
- elimina el default que forzaba `get_storage_path()` en `_main()`
- anade `--sqlite-path <path>` como override explicito
- pasa `db_path=args.sqlite_path` al generador anual
- anade serializacion JSON segura para `datetime` y `date`
Regresion anadida:
- `scripts/run-stats-validation.ps1`
- valida que el CLI anual use `db_path=None` por defecto
- valida que `--sqlite-path` siga funcionando
- valida por import que `generate_annual_ranking_snapshot(..., db_path=None)` seleccione PostgreSQL cuando `HLL_BACKEND_DATABASE_URL` esta configurado
Comando operativo final:
- local / contenedor backend:
- `python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing`
Comando Docker final recomendado:
- `docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing`
Validaciones ejecutadas:
- `python -m py_compile backend/app/rcon_annual_rankings.py`
- validacion local por import de `_main()`:
- confirma `db_path=None` por defecto
- confirma serializacion JSON correcta de `datetime` y `date`
- validacion local por import de `generate_annual_ranking_snapshot(..., db_path=None)`:
- confirma seleccion de PostgreSQL cuando `HLL_BACKEND_DATABASE_URL` esta configurado
- ejecucion manual real del CLI:
- `python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing --sqlite-path data/hll_vietnam_dev.sqlite3`
- validacion local del endpoint anual por import:
- `/api/ranking?timeframe=annual&year=<current_year>&server_id=all&metric=kills&limit=20`
- mantiene `status=ok`, `timeframe=annual`, `metric=kills`, `snapshot_status`, `items`, `generated_at`, `window_start` y `window_end`
Resultado de scripts globales:
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
- falla antes de la nueva regresion anual por una asercion de frontend preexistente:
- `Stats page no longer exposes backend state chip.`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- falla por arrastrar el mismo fallo previo de `run-stats-validation.ps1`
Nota de alcance sobre esos fallos:
- la causa no pertenece a esta task backend
- viene del arbol local ya modificado en frontend y de la validacion global que aun espera `id="stats-backend-state"`
- no se corrigio aqui porque el usuario pidio no modificar frontend ni mezclar esta task con `TASK-204`
Como validar en produccion:
1. Ejecutar:
- `docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing`
2. Consultar PostgreSQL:
- `rcon_annual_ranking_snapshots`
- `rcon_annual_ranking_snapshot_items`
3. Confirmar que ya no se escriben filas nuevas en SQLite por defecto.
4. Llamar a:
- `/api/ranking?timeframe=annual&metric=kills&limit=30&year=2026`
5. Confirmar:
- `snapshot_status=ready`
- `items` no vacio cuando exista cobertura anual
- `read_model = rcon-annual-ranking-snapshot`
## Change Budget
- Prefer fewer than 5 modified files.
- Prefer changes under 200 lines when feasible.
- Split the work into follow-up tasks if limits are exceeded.

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
import argparse import argparse
import json import json
from contextlib import closing from contextlib import closing
from datetime import datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage from .config import use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
@@ -547,6 +547,14 @@ def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[
) )
def _json_default(value: object) -> str:
if isinstance(value, datetime):
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if isinstance(value, date):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def _main(argv: list[str] | None = None) -> int: def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate annual ranking snapshots.") parser = argparse.ArgumentParser(description="Generate annual ranking snapshots.")
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
@@ -555,6 +563,12 @@ def _main(argv: list[str] | None = None) -> int:
generate_parser.add_argument("--server-key", default=None) generate_parser.add_argument("--server-key", default=None)
generate_parser.add_argument("--metric", default="kills") generate_parser.add_argument("--metric", default="kills")
generate_parser.add_argument("--limit", type=int, default=20) generate_parser.add_argument("--limit", type=int, default=20)
generate_parser.add_argument(
"--sqlite-path",
type=Path,
default=None,
help="explicit local SQLite override; default operational mode uses PostgreSQL when configured",
)
generate_parser.add_argument("--replace-existing", action="store_true", default=True) generate_parser.add_argument("--replace-existing", action="store_true", default=True)
parser.set_defaults(command="generate") parser.set_defaults(command="generate")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -566,9 +580,16 @@ def _main(argv: list[str] | None = None) -> int:
metric=args.metric, metric=args.metric,
limit=args.limit, limit=args.limit,
replace_existing=args.replace_existing, replace_existing=args.replace_existing,
db_path=get_storage_path(), db_path=args.sqlite_path,
)
print(
json.dumps(
{"status": "ok", "data": payload},
ensure_ascii=True,
indent=2,
default=_json_default,
)
) )
print(json.dumps({"status": "ok", "data": payload}, ensure_ascii=True, indent=2))
return 0 return 0
parser.print_help() parser.print_help()

View File

@@ -2,16 +2,16 @@
Objetivo: operar el ranking anual top 20 de `Stats` de forma reproducible, usando snapshots precomputados para no recalcular anualmente por cada request. Objetivo: operar el ranking anual top 20 de `Stats` de forma reproducible, usando snapshots precomputados para no recalcular anualmente por cada request.
## 1) Propósito del snapshot anual top 20 ## 1) Proposito del snapshot anual top 20
El snapshot anual proporciona la tabla de posiciones de jugadores para el bloque de ranking anual de `Stats` con impacto bajo en latencia y costo de consulta. El snapshot anual proporciona la tabla de posiciones de jugadores para el bloque de ranking anual de `Stats` con impacto bajo en latencia y costo de consulta.
Objetivos del diseño: Objetivos del diseno:
- entregar `top 20` de la temporada por métrica (V1: `kills`); - entregar `top 20` de la temporada por metrica (V1: `kills`);
- consumir un resultado estable desde API pública sin recalcular toda la historia anual en cada request; - consumir un resultado estable desde API publica sin recalcular toda la historia anual en cada request;
- permitir validación operativa clara de si el ranking está disponible o no; - permitir validacion operativa clara de si el ranking esta disponible o no;
- soportar regeneración controlada por proceso de mantenimiento. - soportar regeneracion controlada por proceso de mantenimiento.
## 2) Fuente de datos ## 2) Fuente de datos
@@ -24,12 +24,12 @@ Filtro principal:
- `matches.source_basis = admin-log-match-ended` - `matches.source_basis = admin-log-match-ended`
Reglas clave de cálculo: Reglas clave de calculo:
- ventanas por año (`YYYY-01-01T00:00:00Z` a `YYYY+1-01-01T00:00:00Z`); - ventanas por ano (`YYYY-01-01T00:00:00Z` a `YYYY+1-01-01T00:00:00Z`);
- acumulado de `kills`, `deaths`, `teamkills` por `player_id`; - acumulado de `kills`, `deaths`, `teamkills` por `player_id`;
- orden por `metric_value` desc, `matches_considered` desc, `player_name` asc; - orden por `metric_value` desc, `matches_considered` desc, `player_name` asc;
- sólo posiciones con actividad válida y `player_name` no vacío. - solo posiciones con actividad valida y `player_name` no vacio.
## 3) Endpoint consumidor ## 3) Endpoint consumidor
@@ -44,28 +44,42 @@ En V1:
## 4) Como generar snapshot anual ## 4) Como generar snapshot anual
La generación se hace fuera de la API (job/comando) para precomputar: La generacion se hace fuera de la API (job/comando) para precomputar:
1. Definir año objetivo `year`. 1. Definir ano objetivo `year`.
2. Definir alcance `server_id` (`all` para global o servidor específico). 2. Definir alcance `server_id` (`all` para global o servidor especifico).
3. Ejecutar generador de snapshot anual con la configuración necesaria. 3. Ejecutar generador de snapshot anual con la configuracion necesaria.
4. Generador: 4. Generador:
- elimina snapshot previo del mismo `(year, server_id, metric)` si existe; - elimina snapshot previo del mismo `(year, server_id, metric)` si existe;
- recalcula top-k desde datos materializados RCON; - recalcula top-k desde datos materializados RCON;
- guarda cabecera + items del ranking en tablas snapshots; - guarda cabecera + items del ranking en tablas snapshots;
- persiste metadatos (`generated_at`, ventana y conteo fuente). - persiste metadatos (`generated_at`, ventana y conteo fuente).
### Comando recomendado (si el entorno lo habilita) ### Comando recomendado
```bash ```bash
python -m app.rcon_annual_rankings generate --year 2026 --server-key all --metric kills --limit 20 python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing
``` ```
Notas: Notas:
- El comando puede fallar si la capa de datos local no está inicializada; - Cuando `HLL_BACKEND_DATABASE_URL` esta configurado, el comando usa PostgreSQL por defecto.
- en entornos manuales, validar entorno y ruta de DB antes de ejecutar; - SQLite queda solo como override explicito mediante `--sqlite-path <path>`.
- si no hay datos, se puede generar un snapshot vacío (ready con `items=[]`). - El comando puede fallar si la capa de datos local no esta inicializada.
- En entornos manuales, validar entorno y storage objetivo antes de ejecutar.
- Si no hay datos, se puede generar un snapshot vacio (`ready` con `items=[]`).
### Override local SQLite
```bash
python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing --sqlite-path backend/data/hll_vietnam_dev.sqlite3
```
### Comando Docker recomendado
```bash
docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing
```
## 5) Como regenerarlo ## 5) Como regenerarlo
@@ -77,19 +91,19 @@ Regenerar cuando:
Procedimiento: Procedimiento:
1. Ejecutar nuevamente el generador con el mismo `year` y `server_id`; 1. Ejecutar nuevamente el generador con el mismo `year` y `server_id`.
2. Aceptar reemplazo seguro del snapshot existente; 2. Aceptar reemplazo seguro del snapshot existente.
3. Validar que el snapshot nuevo refleje el recuento de partidas fuente actualizado. 3. Validar que el snapshot nuevo refleje el recuento de partidas fuente actualizado.
Recomendación: Recomendacion:
- programar recálculo en ventanas de mantenimiento (idealmente cierre anual o rutina periódica definida por operaciones). - programar recalculo en ventanas de mantenimiento (idealmente cierre anual o rutina periodica definida por operaciones).
## 6) Como validar que existe un snapshot ## 6) Como validar que existe un snapshot
Verificación local (reconstruir estado esperable): Verificacion local:
1. Consultar API de ranking anual del año objetivo. 1. Consultar API de ranking anual del ano objetivo.
2. Revisar `snapshot_status` en respuesta: 2. Revisar `snapshot_status` en respuesta:
- `ready`: existe snapshot; - `ready`: existe snapshot;
- `missing`: no existe snapshot. - `missing`: no existe snapshot.
@@ -112,37 +126,36 @@ Usar llamadas directas a backend para validar estado y contenido:
Checklist: Checklist:
- HTTP 200 esperado para parámetros válidos de V1. - HTTP 200 esperado para parametros validos de V1.
- `status` debe ser `"ok"` con estructura de data consistente. - `status` debe ser `"ok"` con estructura de data consistente.
- Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit` - Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit` cuando el snapshot fue generado con un limite menor o contiene menos filas.
cuando el snapshot fue generado con un limite menor o contiene menos filas. - En V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking.
- en V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking.
## 8) Como validar desde frontend Stats ## 8) Como validar desde frontend Stats
Desde `frontend/stats.html`: Desde `frontend/stats.html`:
1. Abrir la pestaña `Stats`. 1. Abrir la pestana `Stats`.
2. Ejecutar consulta anual usando `year`. 2. Ejecutar consulta anual usando `year`.
3. Confirmar: 3. Confirmar:
- estado de mensaje de carga/success/empty/error; - estado de mensaje de carga/success/empty/error;
- render de filas cuando haya items; - render de filas cuando haya items;
- texto explícito cuando el snapshot está `missing`; - texto explicito cuando el snapshot esta `missing`;
- texto explícito cuando el snapshot existe pero no tiene items. - texto explicito cuando el snapshot existe pero no tiene items.
4. Confirmar que no rompe bloque semanal/mensual ni búsqueda cuando el anual no está disponible. 4. Confirmar que no rompe bloque semanal/mensual ni busqueda cuando el anual no esta disponible.
5. Si endpoint retorna métricas inválidas, validar estado de warning en UI y que no cambia el resto del flujo. 5. Si endpoint retorna metricas invalidas, validar estado de warning en UI y que no cambia el resto del flujo.
## 9) Casos esperados ## 9) Casos esperados
### 9.1 `snapshot_status=ready` con items ### 9.1 `snapshot_status=ready` con items
Respuesta típica: Respuesta tipica:
- `status: "ok"` - `status: "ok"`
- `data.snapshot_status: "ready"` - `data.snapshot_status: "ready"`
- `data.items` con ranking ordenado. - `data.items` con ranking ordenado.
Debe mostrarse top 20 con campos mínimos: Debe mostrarse top 20 con campos minimos:
- `ranking_position` - `ranking_position`
- `player_name` - `player_name`
@@ -155,55 +168,55 @@ Debe mostrarse top 20 con campos mínimos:
### 9.2 `snapshot_status=ready` sin items ### 9.2 `snapshot_status=ready` sin items
Respuesta típica: Respuesta tipica:
- `snapshot_status: "ready"` - `snapshot_status: "ready"`
- `items: []` - `items: []`
- `generated_at` puede estar presente - `generated_at` puede estar presente
Debe renderizar estado "snapshot ready vacío" y no tratarlo como error de sistema. Debe renderizar estado "snapshot ready vacio" y no tratarlo como error de sistema.
### 9.3 `snapshot_status=missing` ### 9.3 `snapshot_status=missing`
Respuesta típica: Respuesta tipica:
- `snapshot_status: "missing"` - `snapshot_status: "missing"`
- `items: []` - `items: []`
Debe mostrar estado informativo claro de que el ranking no fue generado aún. Debe mostrar estado informativo claro de que el ranking no fue generado aun.
### 9.4 Métrica no soportada ### 9.4 Metrica no soportada
Respuesta típica: Respuesta tipica:
- error request (400) con mensaje de métrica inválida/no soportada. - error request (400) con mensaje de metrica invalida/no soportada.
La UI/backend no debe intentar recalcular ni degradar a comportamiento inesperado. La UI/backend no debe intentar recalcular ni degradar a comportamiento inesperado.
## 10) Advertencias operativas ## 10) Advertencias operativas
- No reactivar Elo/MMR ni lógica dependiente en este bloque. - No reactivar Elo/MMR ni logica dependiente en este bloque.
- No reintroducir `Comunidad Hispana #03` como alcance normal. - No reintroducir `Comunidad Hispana #03` como alcance normal.
- No usar scoreboard público como fuente primaria si RCON materializado está disponible. - No usar scoreboard publico como fuente primaria si RCON materializado esta disponible.
- No recalcular ranking anual completo en cada request público; siempre leer snapshot. - No recalcular ranking anual completo en cada request publico; siempre leer snapshot.
- No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png` en este runbook. - No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png` en este runbook.
## Checklist de operación ## Checklist de operacion
- [ ] Definir año/servidor/limite. - [ ] Definir ano/servidor/limite.
- [ ] Ejecutar generación o regeneración. - [ ] Ejecutar generacion o regeneracion.
- [ ] Confirmar respuesta de API por año objetivo. - [ ] Confirmar respuesta de API por ano objetivo.
- [ ] Confirmar estado en UI de Stats. - [ ] Confirmar estado en UI de Stats.
- [ ] Registrar fecha/hora de generación y responsable. - [ ] Registrar fecha/hora de generacion y responsable.
- [ ] Si faltan datos esperados, revisar pipeline RCON materializado. - [ ] Si faltan datos esperados, revisar pipeline RCON materializado.
## Validación de la task ## Validacion de la task
- `docs/annual-ranking-snapshot-runbook.md` creado. - `docs/annual-ranking-snapshot-runbook.md` actualizado.
- Cambios esperados únicamente de documentación. - Cambios esperados unicamente de documentacion.
- No se aplican tests automáticos (task de documentación-only); se documenta explícitamente. - No se aplican tests automaticos en este runbook; la validacion real se ejecuta en la task de backend correspondiente.
## Próximos pasos recomendados ## Proximos pasos recomendados
- Si el bloque muestra estados esperados y refrescos, conectar este runbook con operación de mantenimiento programada. - Si el bloque muestra estados esperados y refrescos, conectar este runbook con operacion de mantenimiento programada.
- Mantener la misma política de fuentes en futuros reportes o automatizaciones de jobs. - Mantener la misma politica de fuentes en futuros reportes o automatizaciones de jobs.

View File

@@ -128,9 +128,11 @@ from app.routes import resolve_get_payload
from app.config import use_postgres_rcon_storage from app.config import use_postgres_rcon_storage
import app.postgres_rcon_storage as postgres_rcon_storage import app.postgres_rcon_storage as postgres_rcon_storage
import app.historical_runner as historical_runner import app.historical_runner as historical_runner
import app.rcon_annual_rankings as annual_rankings
import app.rcon_historical_leaderboards as ranking_leaderboards import app.rcon_historical_leaderboards as ranking_leaderboards
import app.rcon_historical_player_stats as player_search_stats import app.rcon_historical_player_stats as player_search_stats
generate_annual_ranking_snapshot = annual_rankings.generate_annual_ranking_snapshot
initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage
generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot
@@ -548,6 +550,147 @@ def validate_ranking_snapshot_postgres_selection():
postgres_rcon_storage.connect_postgres_compat = original_connect_postgres_compat postgres_rcon_storage.connect_postgres_compat = original_connect_postgres_compat
def validate_annual_ranking_cli_defaults():
original_generate_annual_ranking_snapshot = annual_rankings.generate_annual_ranking_snapshot
captured = {}
def fake_generate_annual_ranking_snapshot(**kwargs):
captured.update(kwargs)
return {
"status": "ok",
"snapshot": {
"generated_at": datetime(2026, 6, 9, 8, 0, 0, tzinfo=timezone.utc),
"window_start": date(2026, 1, 1),
},
"items": [],
}
annual_rankings.generate_annual_ranking_snapshot = fake_generate_annual_ranking_snapshot
try:
stdout_buffer = StringIO()
with redirect_stdout(stdout_buffer):
exit_code = annual_rankings._main([
"generate",
"--year", "2026",
"--server-key", "all-servers",
"--metric", "kills",
"--limit", "30",
])
require(exit_code == 0, "Annual ranking CLI should exit 0 for a valid command.")
require(
captured.get("db_path") is None,
"Annual ranking CLI should use PostgreSQL-compatible db_path=None by default.",
)
serialized_default_payload = json.loads(stdout_buffer.getvalue())
require(
serialized_default_payload.get("data", {}).get("snapshot", {}).get("generated_at") == "2026-06-09T08:00:00Z",
"Annual ranking CLI should serialize datetime values as ISO strings.",
)
require(
serialized_default_payload.get("data", {}).get("snapshot", {}).get("window_start") == "2026-01-01",
"Annual ranking CLI should serialize date values as ISO strings.",
)
captured.clear()
stdout_buffer = StringIO()
with redirect_stdout(stdout_buffer):
exit_code = annual_rankings._main([
"generate",
"--year", "2026",
"--server-key", "all-servers",
"--metric", "kills",
"--limit", "30",
"--sqlite-path", "backend/data/hll_vietnam_dev.sqlite3",
])
require(exit_code == 0, "Annual ranking CLI with --sqlite-path should exit 0.")
require(
captured.get("db_path") == Path("backend/data/hll_vietnam_dev.sqlite3"),
"Annual ranking CLI should pass the explicit --sqlite-path override through to generate_annual_ranking_snapshot.",
)
finally:
annual_rankings.generate_annual_ranking_snapshot = original_generate_annual_ranking_snapshot
def validate_annual_ranking_postgres_selection():
original_database_url = os.environ.get("HLL_BACKEND_DATABASE_URL")
original_initialize_materialized = annual_rankings.initialize_rcon_materialized_storage
original_connect_postgres_compat = postgres_rcon_storage.connect_postgres_compat
original_count_matches_in_window = annual_rankings._count_matches_in_window
original_find_existing_snapshot = annual_rankings._find_existing_snapshot
original_fetch_annual_ranking_rows = annual_rankings._fetch_annual_ranking_rows
original_delete_existing_snapshot = annual_rankings._delete_existing_snapshot
original_insert_snapshot = annual_rankings._insert_snapshot
original_insert_items = annual_rankings._insert_items
original_get_snapshot = annual_rankings._get_snapshot
original_list_items = annual_rankings._list_items
calls = {"postgres_connect": 0}
@contextmanager
def fake_connect_postgres_compat():
calls["postgres_connect"] += 1
yield object()
os.environ["HLL_BACKEND_DATABASE_URL"] = "postgresql://validation-user:validation-pass@127.0.0.1:5432/hll_validation"
annual_rankings.initialize_rcon_materialized_storage = (
lambda db_path=None: Path("backend/data/hll_vietnam_dev.sqlite3")
)
postgres_rcon_storage.connect_postgres_compat = fake_connect_postgres_compat
annual_rankings._count_matches_in_window = lambda **kwargs: 0
annual_rankings._find_existing_snapshot = lambda **kwargs: None
annual_rankings._fetch_annual_ranking_rows = lambda **kwargs: []
annual_rankings._delete_existing_snapshot = lambda **kwargs: None
annual_rankings._insert_snapshot = lambda **kwargs: 1
annual_rankings._insert_items = lambda **kwargs: None
annual_rankings._get_snapshot = lambda **kwargs: {
"id": 1,
"year": 2026,
"server_key": "all-servers",
"metric": "kills",
"limit_size": 30,
"generated_at": "2026-06-09T08:00:00Z",
}
annual_rankings._list_items = lambda **kwargs: []
try:
require(
use_postgres_rcon_storage(explicit_sqlite_path=None) is True,
"PostgreSQL storage should be selected for annual ranking when DATABASE_URL is configured and no explicit SQLite path is provided.",
)
require(
use_postgres_rcon_storage(explicit_sqlite_path=Path("backend/data/hll_vietnam_dev.sqlite3")) is False,
"Explicit SQLite paths should still disable PostgreSQL storage selection for annual ranking.",
)
payload = generate_annual_ranking_snapshot(
year=2026,
server_key="all-servers",
metric="kills",
limit=30,
replace_existing=True,
db_path=None,
)
require(payload.get("status") == "ok", "Annual ranking snapshot generator should still return ok in the PostgreSQL selection validation.")
require(
calls["postgres_connect"] == 1,
"Annual ranking snapshot generator should use PostgreSQL when db_path=None and DATABASE_URL is configured.",
)
finally:
if original_database_url is None:
os.environ.pop("HLL_BACKEND_DATABASE_URL", None)
else:
os.environ["HLL_BACKEND_DATABASE_URL"] = original_database_url
annual_rankings.initialize_rcon_materialized_storage = original_initialize_materialized
postgres_rcon_storage.connect_postgres_compat = original_connect_postgres_compat
annual_rankings._count_matches_in_window = original_count_matches_in_window
annual_rankings._find_existing_snapshot = original_find_existing_snapshot
annual_rankings._fetch_annual_ranking_rows = original_fetch_annual_ranking_rows
annual_rankings._delete_existing_snapshot = original_delete_existing_snapshot
annual_rankings._insert_snapshot = original_insert_snapshot
annual_rankings._insert_items = original_insert_items
annual_rankings._get_snapshot = original_get_snapshot
annual_rankings._list_items = original_list_items
def validate_postgres_player_search_index_schema_path(): def validate_postgres_player_search_index_schema_path():
require( require(
"CREATE TABLE IF NOT EXISTS player_search_index" in postgres_rcon_storage.RCON_SCHEMA_SQL, "CREATE TABLE IF NOT EXISTS player_search_index" in postgres_rcon_storage.RCON_SCHEMA_SQL,
@@ -1506,6 +1649,8 @@ validate_ranking_snapshot_cli_defaults()
validate_ranking_snapshot_postgres_selection() validate_ranking_snapshot_postgres_selection()
validate_ranking_snapshot_bulk_refresh() validate_ranking_snapshot_bulk_refresh()
validate_ranking_snapshot_bulk_partial_failure() validate_ranking_snapshot_bulk_partial_failure()
validate_annual_ranking_cli_defaults()
validate_annual_ranking_postgres_selection()
validate_postgres_player_search_index_schema_path() validate_postgres_player_search_index_schema_path()
validate_player_search_index_cli_defaults() validate_player_search_index_cli_defaults()
validate_player_search_index_refresh_and_search() validate_player_search_index_refresh_and_search()
@@ -1894,6 +2039,8 @@ print(json.dumps({
"ranking-snapshot-postgres-selection", "ranking-snapshot-postgres-selection",
"ranking-snapshot-bulk-refresh", "ranking-snapshot-bulk-refresh",
"ranking-snapshot-bulk-partial-failure", "ranking-snapshot-bulk-partial-failure",
"annual-ranking-cli-postgres-default",
"annual-ranking-postgres-selection",
"ranking-snapshot-generator", "ranking-snapshot-generator",
"ranking-snapshot-ready", "ranking-snapshot-ready",
"ranking-snapshot-missing", "ranking-snapshot-missing",