Files
praxis/server/operator/_common.py
T
Praxis CI a7f7c4e1cc feat(P02): SLICE-08 operator API cohort endpoints — auth-gated, k-anon
TASK-08-01: server/operator/cohort.py — GET /api/operator/cohort practice
  volume view (sessions_count, active_learners_count), k-anon display.
TASK-08-02: server/operator/mastery.py — GET /api/operator/mastery
  (gate_open_rate, median_mastery_score, rubric_criterion_means).
TASK-08-03: server/operator/failure_patterns.py — GET /api/operator/failure-patterns
  (failure_mode:*, branch:* metrics).
TASK-08-04: server/operator/credentials.py — GET /api/operator/credentials
  + POST /api/operator/credentials/{id}/revoke (VC management, D-057).
TASK-08-05: tests/test_operator_endpoints.py — 401/200/503, suppressed
  cells value=null, last_updated=max, revoke, no per-learner data (R-DASH-02).
server/operator/_common.py — shared Cell/PathView/ViewResponse models +
  all_recent_aggregates query + group_by_path helper.

All endpoints auth-gated via Depends(current_operator) (D-057). No PII
beyond aggregates (D-031, R-DASH-02).

---ci---
project: praxis
phase: 2
milestone: v0.4
status: execute
persona: backend-engineer
task: 08-01..08-05
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01]
---/ci---
2026-08-04 02:03:50 +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",
]