Files
praxis/server/operator/_common.py
T
Praxis CI ec6fcc64bc feat(milestone): merge phase/02 cohort-dashboard → milestone/v0.4-operator-tier
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---
2026-08-04 11:43:34 +00:00

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",
]