ec6fcc64bc
Phase 2 complete — Cohort Dashboard + Aggregation: - Cohort aggregation pipeline (k-anon ≥10 write-time suppression, async hook, nightly 03:00 CT reconcile) - 4 auth-gated operator API endpoints (cohort, mastery, failure-patterns, credentials) - React cohort dashboard (BrowserRouter, login, 3 views, inline SVG sparklines, auth gate) - SPA fallback via SpaStaticFiles subclass (G-041 — NOT catch-all route) - G-038 differencing-attack test (unit + API e2e) - 317 pytest pass, 36 skip, 0 fail; 17/17 vitest pass; npm build + typecheck clean ---ci--- project: praxis phase: 2 milestone: v0.4 status: complete requirements: covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02] partial: [] ---/ci---
93 lines
2.9 KiB
Python
93 lines
2.9 KiB
Python
"""Shared helpers for operator API endpoints (SLICE-08).
|
|
|
|
Common response models + the recent-aggregates query used by all 3 cohort
|
|
view endpoints (cohort, mastery, failure-patterns). Kept here to avoid
|
|
duplicating the Pydantic models + pool query across 3 files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, Request, status
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class Cell(BaseModel):
|
|
metric: str
|
|
window_start: _dt.date | None = None
|
|
window_end: _dt.date | None = None
|
|
value: float | None = None
|
|
cell_count: int = 0
|
|
cell_suppressed: bool = False
|
|
updated_at: _dt.datetime | None = None
|
|
|
|
|
|
class PathView(BaseModel):
|
|
path: str
|
|
metrics: list[Cell]
|
|
|
|
|
|
class ViewResponse(BaseModel):
|
|
views: list[PathView]
|
|
last_updated: _dt.datetime | None = None
|
|
|
|
|
|
async def require_pg_store(request: Request):
|
|
pg_store = getattr(request.app.state, "pg_store", None)
|
|
if pg_store is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="operator tier unavailable (no Postgres)",
|
|
)
|
|
return pg_store
|
|
|
|
|
|
async def all_recent_aggregates(pg_store, since: _dt.date) -> list[dict[str, Any]]:
|
|
async with pg_store.pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"SELECT path, metric, window_start, window_end, value, "
|
|
"cell_count, cell_suppressed, updated_at "
|
|
"FROM cohort_aggregates WHERE window_start >= $1 "
|
|
"ORDER BY path, metric, window_start",
|
|
since,
|
|
)
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def cell_from_row(row: dict[str, Any]) -> Cell:
|
|
return Cell(
|
|
metric=row.get("metric", ""),
|
|
window_start=row.get("window_start"),
|
|
window_end=row.get("window_end"),
|
|
value=float(row["value"]) if row.get("value") is not None else None,
|
|
cell_count=int(row.get("cell_count") or 0),
|
|
cell_suppressed=bool(row.get("cell_suppressed") or False),
|
|
updated_at=row.get("updated_at"),
|
|
)
|
|
|
|
|
|
def group_by_path(
|
|
rows: list[dict[str, Any]],
|
|
metric_filter: set[str] | None = None,
|
|
) -> tuple[list[PathView], _dt.datetime | None]:
|
|
by_path: dict[str, list[dict[str, Any]]] = {}
|
|
last_updated: _dt.datetime | None = None
|
|
for r in rows:
|
|
if metric_filter is not None and r.get("metric") not in metric_filter:
|
|
continue
|
|
by_path.setdefault(r["path"], []).append(r)
|
|
ua = r.get("updated_at")
|
|
if isinstance(ua, _dt.datetime) and (last_updated is None or ua > last_updated):
|
|
last_updated = ua
|
|
views = [PathView(path=p, metrics=[cell_from_row(c) for c in cells])
|
|
for p, cells in by_path.items()]
|
|
return views, last_updated
|
|
|
|
|
|
__all__ = [
|
|
"Cell", "PathView", "ViewResponse",
|
|
"require_pg_store", "all_recent_aggregates",
|
|
"cell_from_row", "group_by_path",
|
|
] |