Stabilize player stats and historical detail public reads
This commit is contained in:
@@ -1147,6 +1147,30 @@ def build_historical_match_detail_payload(
|
||||
"item": item,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Detalle de partida historica",
|
||||
"context": "historical-match-detail",
|
||||
"source": "rcon-historical-competitive-read-model",
|
||||
"found": False,
|
||||
**build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source=SOURCE_KIND_RCON,
|
||||
fallback_used=False,
|
||||
fallback_reason=None,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_RCON,
|
||||
role="primary",
|
||||
status="empty",
|
||||
reason="historical-match-detail-read-model-missing",
|
||||
)
|
||||
],
|
||||
),
|
||||
"item": None,
|
||||
},
|
||||
}
|
||||
|
||||
item = get_historical_match_detail(server_slug=server_slug, match_id=match_id)
|
||||
return {
|
||||
|
||||
@@ -453,9 +453,10 @@ class PostgresCompatConnection:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connect_postgres_compat():
|
||||
def connect_postgres_compat(*, initialize: bool = True):
|
||||
"""Yield a query shim that accepts the phase-1 SQLite-style placeholders."""
|
||||
initialize_postgres_rcon_storage()
|
||||
if initialize:
|
||||
initialize_postgres_rcon_storage()
|
||||
with connect_postgres() as connection:
|
||||
yield PostgresCompatConnection(connection)
|
||||
|
||||
|
||||
@@ -310,66 +310,74 @@ def get_materialized_rcon_match_detail(
|
||||
server_key: str,
|
||||
match_key: str,
|
||||
db_path: Path | None = None,
|
||||
ensure_storage: bool = False,
|
||||
) -> dict[str, object] | None:
|
||||
"""Return one materialized match with player stats."""
|
||||
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
||||
resolved_path = (
|
||||
initialize_rcon_materialized_storage(db_path=db_path)
|
||||
if ensure_storage
|
||||
else (db_path or get_storage_path())
|
||||
)
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
|
||||
connection_scope = connect_postgres_compat()
|
||||
connection_scope = connect_postgres_compat(initialize=ensure_storage)
|
||||
else:
|
||||
connection_scope = closing(connect_sqlite_readonly(resolved_path))
|
||||
with connection_scope as connection:
|
||||
match = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM rcon_materialized_matches
|
||||
WHERE match_key = ?
|
||||
AND (target_key = ? OR external_server_id = ?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(match_key, server_key, server_key),
|
||||
).fetchone()
|
||||
if match is None and match_key.startswith(f"{server_key}:"):
|
||||
try:
|
||||
with connection_scope as connection:
|
||||
match = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM rcon_materialized_matches
|
||||
WHERE match_key = ?
|
||||
AND (target_key = ? OR external_server_id = ?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(match_key,),
|
||||
(match_key, server_key, server_key),
|
||||
).fetchone()
|
||||
if match is None:
|
||||
return None
|
||||
stat_rows = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM rcon_match_player_stats
|
||||
WHERE target_key = ? AND match_key = ?
|
||||
ORDER BY kills DESC, deaths ASC, player_name ASC
|
||||
""",
|
||||
(match["target_key"], match["match_key"]),
|
||||
).fetchall()
|
||||
timeline_rows = connection.execute(
|
||||
"""
|
||||
SELECT event_type, COUNT(*) AS event_count
|
||||
FROM rcon_admin_log_events
|
||||
WHERE target_key = ?
|
||||
AND server_time IS NOT NULL
|
||||
AND (? IS NULL OR server_time >= ?)
|
||||
AND (? IS NULL OR server_time <= ?)
|
||||
GROUP BY event_type
|
||||
ORDER BY event_count DESC, event_type ASC
|
||||
""",
|
||||
(
|
||||
match["target_key"],
|
||||
match["started_server_time"],
|
||||
match["started_server_time"],
|
||||
match["ended_server_time"],
|
||||
match["ended_server_time"],
|
||||
),
|
||||
).fetchall()
|
||||
if match is None and match_key.startswith(f"{server_key}:"):
|
||||
match = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM rcon_materialized_matches
|
||||
WHERE match_key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(match_key,),
|
||||
).fetchone()
|
||||
if match is None:
|
||||
return None
|
||||
stat_rows = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM rcon_match_player_stats
|
||||
WHERE target_key = ? AND match_key = ?
|
||||
ORDER BY kills DESC, deaths ASC, player_name ASC
|
||||
""",
|
||||
(match["target_key"], match["match_key"]),
|
||||
).fetchall()
|
||||
timeline_rows = connection.execute(
|
||||
"""
|
||||
SELECT event_type, COUNT(*) AS event_count
|
||||
FROM rcon_admin_log_events
|
||||
WHERE target_key = ?
|
||||
AND server_time IS NOT NULL
|
||||
AND (? IS NULL OR server_time >= ?)
|
||||
AND (? IS NULL OR server_time <= ?)
|
||||
GROUP BY event_type
|
||||
ORDER BY event_count DESC, event_type ASC
|
||||
""",
|
||||
(
|
||||
match["target_key"],
|
||||
match["started_server_time"],
|
||||
match["started_server_time"],
|
||||
match["ended_server_time"],
|
||||
match["ended_server_time"],
|
||||
),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return {
|
||||
"match": dict(match),
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
import unicodedata
|
||||
|
||||
from .config import use_postgres_rcon_storage
|
||||
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 .rcon_historical_leaderboards import select_leaderboard_window
|
||||
@@ -227,7 +227,7 @@ def search_rcon_materialized_players(
|
||||
limit: int = 10,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Search players from the read model first, with runtime fallback."""
|
||||
"""Search players from the public read model without runtime fallback."""
|
||||
normalized_query = query.strip()
|
||||
if not normalized_query:
|
||||
raise ValueError("Query cannot be empty.")
|
||||
@@ -241,18 +241,19 @@ def search_rcon_materialized_players(
|
||||
if read_model_result is not None:
|
||||
return read_model_result
|
||||
|
||||
runtime_result = _search_rcon_materialized_players_runtime(
|
||||
query=normalized_query,
|
||||
server_id=server_id,
|
||||
limit=limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
runtime_result["source"] = {
|
||||
"read_model": "rcon-materialized-admin-log-player-stats",
|
||||
"fallback_used": True,
|
||||
"fallback_reason": fallback_reason or "player-search-index-unavailable",
|
||||
resolved_server_id = _normalize_server_id(server_id)
|
||||
return {
|
||||
"server_id": resolved_server_id,
|
||||
"query": normalized_query,
|
||||
"items": [],
|
||||
"source": {
|
||||
"read_model": "player-search-index",
|
||||
"status": "unavailable",
|
||||
"fallback_used": False,
|
||||
"fallback_reason": None,
|
||||
"missing_reason": fallback_reason or "player-search-index-unavailable",
|
||||
},
|
||||
}
|
||||
return runtime_result
|
||||
|
||||
|
||||
def _search_rcon_materialized_players_runtime(
|
||||
@@ -370,22 +371,12 @@ def get_rcon_materialized_player_stats(
|
||||
if read_model_result is not None:
|
||||
return read_model_result
|
||||
|
||||
runtime_result = _get_rcon_materialized_player_stats_runtime(
|
||||
return _build_missing_player_period_stats_result(
|
||||
player_id=normalized_player_id,
|
||||
server_id=server_id,
|
||||
timeframe=resolved_timeframe,
|
||||
db_path=db_path,
|
||||
missing_reason=fallback_reason or "player-period-stats-unavailable",
|
||||
)
|
||||
runtime_source = dict(runtime_result.get("source") or {})
|
||||
runtime_source.update(
|
||||
{
|
||||
"read_model": "player-period-stats",
|
||||
"fallback_used": True,
|
||||
"fallback_reason": fallback_reason or "player-period-stats-unavailable",
|
||||
}
|
||||
)
|
||||
runtime_result["source"] = runtime_source
|
||||
return runtime_result
|
||||
|
||||
return _get_rcon_materialized_player_stats_runtime(
|
||||
player_id=normalized_player_id,
|
||||
@@ -488,13 +479,13 @@ def _get_player_period_stats_read_model(
|
||||
timeframe: str,
|
||||
db_path: Path | None = None,
|
||||
) -> tuple[dict[str, object] | None, str | None]:
|
||||
resolved_path = initialize_player_period_stats_storage(db_path=db_path)
|
||||
resolved_path = _resolve_rcon_read_model_path(db_path=db_path)
|
||||
resolved_server_id = _normalize_server_id(server_id)
|
||||
required_periods = sorted({timeframe, "weekly", "monthly"})
|
||||
placeholders = ", ".join(["?"] * len(required_periods))
|
||||
|
||||
try:
|
||||
with _connect_scope(resolved_path, db_path=db_path) as connection:
|
||||
with _connect_read_scope(resolved_path, db_path=db_path) as connection:
|
||||
scope_rows = connection.execute(
|
||||
f"""
|
||||
SELECT period_type, COUNT(*) AS row_count
|
||||
@@ -1085,7 +1076,7 @@ def _search_player_search_index(
|
||||
limit: int = 10,
|
||||
db_path: Path | None = None,
|
||||
) -> tuple[dict[str, object] | None, str | None]:
|
||||
resolved_path = initialize_player_search_index_storage(db_path=db_path)
|
||||
resolved_path = _resolve_rcon_read_model_path(db_path=db_path)
|
||||
resolved_server_id = _normalize_server_id(server_id)
|
||||
normalized_query = query.strip()
|
||||
normalized_name_query = _normalize_player_search_text(normalized_query)
|
||||
@@ -1097,7 +1088,7 @@ def _search_player_search_index(
|
||||
escaped_id_exact = normalized_query.casefold()
|
||||
|
||||
try:
|
||||
with _connect_scope(resolved_path, db_path=db_path) as connection:
|
||||
with _connect_read_scope(resolved_path, db_path=db_path) as connection:
|
||||
has_rows = connection.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
@@ -1399,6 +1390,14 @@ def _connect_scope(resolved_path: Path, *, db_path: Path | None = None):
|
||||
return closing(connect_sqlite_readonly(resolved_path))
|
||||
|
||||
|
||||
def _connect_read_scope(resolved_path: Path, *, db_path: Path | None = None):
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
|
||||
return connect_postgres_compat(initialize=False)
|
||||
return closing(connect_sqlite_readonly(resolved_path))
|
||||
|
||||
|
||||
def _connect_write_scope(resolved_path: Path, *, db_path: Path | None = None):
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
@@ -1407,6 +1406,42 @@ def _connect_write_scope(resolved_path: Path, *, db_path: Path | None = None):
|
||||
return connect_sqlite_writer(resolved_path)
|
||||
|
||||
|
||||
def _resolve_rcon_read_model_path(*, db_path: Path | None = None) -> Path:
|
||||
return db_path or get_storage_path()
|
||||
|
||||
|
||||
def _build_missing_player_period_stats_result(
|
||||
*,
|
||||
player_id: str,
|
||||
server_id: str | None,
|
||||
timeframe: str,
|
||||
missing_reason: str,
|
||||
) -> dict[str, object]:
|
||||
resolved_server_id = _normalize_server_id(server_id)
|
||||
return {
|
||||
"player_id": player_id,
|
||||
"server_id": resolved_server_id,
|
||||
"timeframe": timeframe,
|
||||
"player_name": None,
|
||||
"window_start": None,
|
||||
"window_end": None,
|
||||
"window_kind": timeframe,
|
||||
"matches_considered": 0,
|
||||
"kills": 0,
|
||||
"deaths": 0,
|
||||
"teamkills": 0,
|
||||
"weekly_ranking": None,
|
||||
"monthly_ranking": None,
|
||||
"source": {
|
||||
"read_model": "player-period-stats",
|
||||
"status": "unavailable",
|
||||
"fallback_used": False,
|
||||
"fallback_reason": None,
|
||||
"missing_reason": missing_reason,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_player_search_text(value: object) -> str:
|
||||
text = str(value or "").strip().casefold()
|
||||
if not text:
|
||||
|
||||
@@ -154,7 +154,11 @@ def get_rcon_historical_match_detail(
|
||||
"""Return one RCON competitive window as a match-detail compatible payload."""
|
||||
from .rcon_admin_log_materialization import get_materialized_rcon_match_detail
|
||||
|
||||
materialized = get_materialized_rcon_match_detail(server_key=server_key, match_key=match_id)
|
||||
materialized = get_materialized_rcon_match_detail(
|
||||
server_key=server_key,
|
||||
match_key=match_id,
|
||||
ensure_storage=False,
|
||||
)
|
||||
if materialized is not None:
|
||||
return _build_materialized_detail_item(materialized)
|
||||
|
||||
|
||||
@@ -7,9 +7,16 @@ import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.historical_storage import upsert_historical_match
|
||||
from app.payloads import build_recent_historical_matches_payload
|
||||
from app.rcon_historical_player_stats import (
|
||||
get_rcon_materialized_player_stats,
|
||||
initialize_player_period_stats_storage,
|
||||
initialize_player_search_index_storage,
|
||||
search_rcon_materialized_players,
|
||||
)
|
||||
from app.rcon_admin_log_materialization import (
|
||||
get_materialized_rcon_match_detail,
|
||||
materialize_rcon_admin_log,
|
||||
@@ -298,6 +305,85 @@ class RconMaterializationPipelineTests(unittest.TestCase):
|
||||
self.assertEqual(payload["data"]["items"][0]["result_source"], "public-scoreboard-fallback")
|
||||
gc.collect()
|
||||
|
||||
def test_public_player_search_uses_read_model_without_initialize_or_runtime_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
initialize_player_search_index_storage(db_path=db_path)
|
||||
_insert_player_search_index_fixture(db_path)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.rcon_historical_player_stats.initialize_player_search_index_storage",
|
||||
side_effect=AssertionError("public read must not initialize player search storage"),
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_player_stats._search_rcon_materialized_players_runtime",
|
||||
side_effect=AssertionError("public read must not use runtime player search fallback"),
|
||||
),
|
||||
):
|
||||
payload = search_rcon_materialized_players(
|
||||
query="Medu",
|
||||
server_id="all",
|
||||
limit=10,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["source"]["read_model"], "player-search-index")
|
||||
self.assertFalse(payload["source"]["fallback_used"])
|
||||
self.assertEqual(payload["items"][0]["player_id"], "76561198092154180")
|
||||
gc.collect()
|
||||
|
||||
def test_public_player_detail_returns_controlled_empty_without_initialize_or_runtime_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "missing.sqlite3"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.rcon_historical_player_stats.initialize_player_period_stats_storage",
|
||||
side_effect=AssertionError("public read must not initialize player period storage"),
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_player_stats._get_rcon_materialized_player_stats_runtime",
|
||||
side_effect=AssertionError("public read must not use runtime player detail fallback"),
|
||||
),
|
||||
):
|
||||
payload = get_rcon_materialized_player_stats(
|
||||
player_id="76561198092154180",
|
||||
server_id="all",
|
||||
timeframe="weekly",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["player_id"], "76561198092154180")
|
||||
self.assertEqual(payload["matches_considered"], 0)
|
||||
self.assertEqual(payload["source"]["read_model"], "player-period-stats")
|
||||
self.assertEqual(payload["source"]["status"], "unavailable")
|
||||
self.assertFalse(payload["source"]["fallback_used"])
|
||||
gc.collect()
|
||||
|
||||
def test_public_match_detail_read_does_not_initialize_materialized_storage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_admin_log_fixture(db_path)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
with patch(
|
||||
"app.rcon_admin_log_materialization.initialize_rcon_materialized_storage",
|
||||
side_effect=AssertionError("public match detail read must not initialize storage"),
|
||||
):
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id="comunidad-hispana-01:100:500:stmariedumontwarfare",
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertEqual(detail["result_source"], "admin-log-match-ended")
|
||||
gc.collect()
|
||||
|
||||
def test_safe_scoreboard_match_url_allowlist_for_active_origins(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_trusted_scoreboard_match_url(
|
||||
@@ -385,6 +471,44 @@ def _persist_scoreboard_match(db_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _insert_player_search_index_fixture(db_path: Path) -> None:
|
||||
import sqlite3
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO player_search_index (
|
||||
server_id,
|
||||
player_id,
|
||||
player_name,
|
||||
normalized_player_name,
|
||||
first_seen_at,
|
||||
last_seen_at,
|
||||
servers_seen,
|
||||
matches_current_year,
|
||||
kills_current_year,
|
||||
deaths_current_year,
|
||||
teamkills_current_year,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"all-servers",
|
||||
"76561198092154180",
|
||||
"Medu",
|
||||
"medu",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2026-06-01T00:00:00Z",
|
||||
'["comunidad-hispana-01"]',
|
||||
12,
|
||||
100,
|
||||
50,
|
||||
0,
|
||||
"2026-06-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _restore_env(name: str, previous_value: str | None) -> None:
|
||||
if previous_value is None:
|
||||
os.environ.pop(name, None)
|
||||
|
||||
Reference in New Issue
Block a user