Add annual ranking snapshot API
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-169-add-annual-ranking-snapshot-api
|
id: TASK-169-add-annual-ranking-snapshot-api
|
||||||
title: Add annual ranking snapshot API
|
title: Add annual ranking snapshot API
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -15,20 +15,20 @@ priority: medium
|
|||||||
|
|
||||||
## Goal
|
## 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
|
## 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
|
## Steps
|
||||||
|
|
||||||
1. Leer los archivos indicados en "Files to Read First".
|
1. Leer los archivos indicados en "Files to Read First".
|
||||||
2. Implementar el endpoint GET `/api/stats/rankings/annual` en backend sin recalcular ranking.
|
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.
|
3. Anadir payload y registro de ruta siguiendo patrones existentes.
|
||||||
4. Validar comportamientos esperados (snapshot existente, faltante, métrica no soportada).
|
4. Validar comportamientos esperados (snapshot existente, faltante, metrica no soportada).
|
||||||
|
|
||||||
## Files to Read First
|
## Files to Read First
|
||||||
|
|
||||||
@@ -47,7 +47,6 @@ Mantiene compatibilidad entre SQLite y Postgres usando el patr
|
|||||||
|
|
||||||
## Expected Files to Modify
|
## 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/rcon_annual_rankings.py`
|
||||||
- `backend/app/payloads.py`
|
- `backend/app/payloads.py`
|
||||||
- `backend/app/routes.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 tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png`.
|
||||||
- No reactivar Elo/MMR.
|
- No reactivar Elo/MMR.
|
||||||
- No incluir Comunidad Hispana #03.
|
- No incluir Comunidad Hispana #03.
|
||||||
- No modificar workers históricos salvo justificación imprescindible.
|
- No modificar workers historicos salvo justificacion imprescindible.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- `python -m compileall backend/app`
|
- `python -m compileall backend/app`
|
||||||
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
- `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
|
## Outcome
|
||||||
|
|
||||||
- registrar archivos modificados
|
### Files modified
|
||||||
- validar comportamiento con y sin snapshot
|
|
||||||
- indicar limitaciones conocidas
|
- `backend/app/rcon_annual_rankings.py`
|
||||||
- proponer siguiente tarea
|
- `backend/app/payloads.py`
|
||||||
|
- `backend/app/routes.py`
|
||||||
|
|
||||||
|
### Endpoint created
|
||||||
|
|
||||||
|
- `GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills&limit=<n>`
|
||||||
|
- 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.
|
||||||
@@ -49,6 +49,7 @@ from .historical_storage import (
|
|||||||
list_weekly_top_kills,
|
list_weekly_top_kills,
|
||||||
)
|
)
|
||||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
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 search_rcon_materialized_players
|
||||||
from .rcon_historical_player_stats import get_rcon_materialized_player_stats
|
from .rcon_historical_player_stats import get_rcon_materialized_player_stats
|
||||||
from .normalizers import normalize_map_name
|
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(
|
def build_weekly_leaderboard_payload(
|
||||||
*,
|
*,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
from contextlib import closing
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path, use_postgres_rcon_storage
|
from .config import get_storage_path, 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_writer
|
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
|
||||||
|
|
||||||
|
|
||||||
def generate_annual_ranking_snapshot(
|
def generate_annual_ranking_snapshot(
|
||||||
@@ -73,7 +74,7 @@ def generate_annual_ranking_snapshot(
|
|||||||
scope_params=scope_params,
|
scope_params=scope_params,
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot_id = _delete_existing_snapshot(
|
_delete_existing_snapshot(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
year=year,
|
year=year,
|
||||||
server_key=normalized_server_key,
|
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:
|
def _normalize_server_key(server_key: str | None) -> str:
|
||||||
normalized = str(server_key or "").strip()
|
normalized = str(server_key or "").strip()
|
||||||
normalized_lower = normalized.lower()
|
normalized_lower = normalized.lower()
|
||||||
@@ -120,7 +190,7 @@ def _normalize_server_key(server_key: str | None) -> str:
|
|||||||
def _normalize_metric(metric: str) -> str:
|
def _normalize_metric(metric: str) -> str:
|
||||||
normalized = str(metric or "kills").strip().lower()
|
normalized = str(metric or "kills").strip().lower()
|
||||||
if normalized != "kills":
|
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
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +211,9 @@ def _fetch_annual_ranking_rows(
|
|||||||
scope_params: list[object],
|
scope_params: list[object],
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
# For now metric support is intentionally narrowed to kills only.
|
# For now metric support is intentionally narrowed to kills only.
|
||||||
|
if metric != "kills":
|
||||||
|
return []
|
||||||
|
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
@@ -167,8 +240,6 @@ def _fetch_annual_ranking_rows(
|
|||||||
""",
|
""",
|
||||||
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
|
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
if metric != "kills":
|
|
||||||
return []
|
|
||||||
return [dict(row) for row in rows]
|
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
|
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(
|
def _find_existing_snapshot(
|
||||||
*,
|
*,
|
||||||
connection: object,
|
connection: object,
|
||||||
@@ -361,9 +462,9 @@ def _get_snapshot(*, connection: object, snapshot_id: int) -> dict[str, object]
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
def _list_items(*, connection: object, snapshot_id: int) -> list[dict[str, object]]:
|
def _list_items(*, connection: object, snapshot_id: int, limit: int | None = None) -> list[dict[str, object]]:
|
||||||
rows = connection.execute(
|
params: list[object] = [snapshot_id]
|
||||||
"""
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
ranking_position,
|
ranking_position,
|
||||||
player_id,
|
player_id,
|
||||||
@@ -377,16 +478,21 @@ def _list_items(*, connection: object, snapshot_id: int) -> list[dict[str, objec
|
|||||||
FROM rcon_annual_ranking_snapshot_items
|
FROM rcon_annual_ranking_snapshot_items
|
||||||
WHERE snapshot_id = ?
|
WHERE snapshot_id = ?
|
||||||
ORDER BY ranking_position ASC
|
ORDER BY ranking_position ASC
|
||||||
""",
|
"""
|
||||||
[snapshot_id],
|
if limit is not None:
|
||||||
).fetchall()
|
query = f"{query}\n LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
rows = connection.execute(query, params).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
|
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
|
||||||
if server_key == ALL_SERVERS_SLUG:
|
if server_key == ALL_SERVERS_SLUG:
|
||||||
return "", []
|
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:
|
def _main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from .config import get_historical_data_source_kind
|
from .config import get_historical_data_source_kind
|
||||||
@@ -17,6 +18,7 @@ from .payloads import (
|
|||||||
build_elo_mmr_player_payload,
|
build_elo_mmr_player_payload,
|
||||||
build_error_payload,
|
build_error_payload,
|
||||||
build_health_payload,
|
build_health_payload,
|
||||||
|
build_annual_ranking_snapshot_payload,
|
||||||
build_historical_leaderboard_payload,
|
build_historical_leaderboard_payload,
|
||||||
build_historical_match_detail_payload,
|
build_historical_match_detail_payload,
|
||||||
build_monthly_mvp_payload,
|
build_monthly_mvp_payload,
|
||||||
@@ -85,6 +87,27 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
limit=limit,
|
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":
|
if parsed.path == "/api/current-match":
|
||||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
if not server_slug:
|
if not server_slug:
|
||||||
@@ -431,6 +454,20 @@ def _parse_limit(query: str) -> int | None:
|
|||||||
return limit
|
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:
|
def _parse_limit_with_default(query: str, default: int = 20) -> int | None:
|
||||||
params = parse_qs(query)
|
params = parse_qs(query)
|
||||||
if "limit" not in params:
|
if "limit" not in params:
|
||||||
|
|||||||
Reference in New Issue
Block a user