QDG Knowledge Base Read-only viewer QWebHub
general

Architecture

Version 1 · Extracted and expanded architecture detail out of the overview page into its own document

Architecture

Single-file FastAPI app (trainer_stats_api.py) — no package/module split. Everything below is one process: routing, SQL, calculation, and persistence.

Data flow

Source DB (MySQL/MariaDB, read-only: race, run, courses)
        │  fetch_trainer_rows() / fetch_jockey_rows()   [365-day lookback, CTE-scoped to
        │                                                 entities running at the target meeting]
        ▼
build_entity_payload(rows, target_date, entity_key, entity_name_key)
        │   pure Python, no DB dependency — same function drives both trainers and
        │   jockeys by swapping entity_key/entity_name_key ("trainer_id"/"trainer" vs
        │   "jockey_id"/"jockey")
        │   calls: calc_wps, calc_meeting_averages, calc_price_band_ivs, top-horses logic
        ▼
JSON payload (List[Dict])
        │
        ├─→ returned directly to caller (GET /trainer-stats, /jockey-stats, /trainerStats, ...)
        │
        └─→ POST /save-stats → persist_meeting_payload() → Target DB: meeting_stats_payloads
                                                             (CREATE TABLE IF NOT EXISTS, then
                                                              INSERT ... ON DUPLICATE KEY UPDATE)

build_entity_payload is the key seam: it only needs a flat list of race+run row dicts with a known shape, so it can in principle be fed rows assembled from something other than the run table (e.g. a JSON payload), though nothing in the current code does that yet.

External API bypass

If config.json → external_api.enabled is true, the three "public" endpoints (/trainerStats, /jockeyStats, /meetingStats) call fetch_meeting_stats_from_external_api() instead of the local DB/calculation path:

  1. _get_ext_api_config() reads the external_api block; returns None (→ local path) if disabled or missing a URL.
  2. _get_ext_api_token() logs in to {url}/auth/login with the configured username/password, decodes the returned JWT's exp claim (without signature verification) to know when to refresh, and caches the token in a module-level dict (_ext_token_cache) — lost on restart.
  3. GET {url}/stats/meeting?date=...&courseId=... is called with Authorization: Bearer <token>. On a 401, the token cache is cleared and the login + request is retried exactly once.
  4. The external response (camelCase field names, e.g. performance365d, winPct) is converted to this API's snake_case shape by _convert_ext_entity / _convert_wps before being returned — so callers of /trainerStats etc. see the same JSON shape regardless of which path served the request.

When external_api.enabled is false or absent (the default), these three endpoints fall back to querying the local DB and running build_entity_payload themselves — identical to what /trainer-stats and /jockey-stats always do.

Two DB connections

  • db_source_connection() — read-only in practice (only ever SELECTs), against race, run, courses.
  • db_target_connection() — read+write, against meeting_stats_payloads (and reads courses to resolve a course_id to a name for /save-stats / /saved-stats).
  • Both use the same get_db_config() (host/port/user/password come from one shared block); only source_db / target_db differ. They can point at the same physical database.
  • Neither connection is pooled — pymysql.connect(...) is opened and closed (via @contextmanager) on every request.

See database for schema and query detail.

Endpoint naming — two parallel implementations

The API exposes trainer/jockey stats twice, under different casing, with different behavior — not just a REST-style alias:

/trainer-stats, /jockey-stats /trainerStats, /jockeyStats, /meetingStats
Casing kebab-case camelCase
Course filter courseName or courseId courseId only
External API bypass never yes, if external_api.enabled
USER_CONTEXT side effect no yes (records {date, course_id} for the caller's IP)
Intended caller internal/manual use external integrators (documented at /api-docs)

Both implementations independently call fetch_*_rows + build_entity_payload for their local path, so a change to the calculation logic (e.g. calc_price_band_ivs) must be correct for both call sites — there's no shared "compute trainer stats for a meeting" function above the row-fetch level.

Middleware

  • CORSMiddleware: allow_origins=["*"], allow_credentials=True, all methods/headers allowed.
  • GZipMiddleware: compresses responses ≥ 1000 bytes.

Server-side state

USER_CONTEXT is a plain in-process dict, {ip: {"date", "course_id", "expires_at"}}, written by the three camelCase endpoints and read by GET /api/user-context (5-minute TTL, checked at read time, never actively swept). It exists purely so the dashboard can restore "last meeting queried" across page loads. It is not persisted, not shared across processes, and holds no auth/authorization meaning. See overview for the auth model itself.

Updated by Claude on Aug. 12, 2026, 9:48 a.m. · Task: updatewiki create project documentation