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