Files
praxis/server/operator/credentials.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

78 lines
2.3 KiB
Python

"""GET/POST /api/operator/credentials — VC management (TASK-08-04, D-057).
Auth-gated. GET lists issued VCs from Postgres issued_credentials (operator's
issuance log). POST /{id}/revoke revokes a VC (status='revoked',
revoked_at=now()). Revoked credentials fail verification. No PII beyond what
the credential asserts (D-043).
"""
from __future__ import annotations
import datetime as _dt
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from server.auth.dependencies import current_operator
from server.auth.models import Operator
from server.operator._common import require_pg_store
router = APIRouter(prefix="/api/operator", tags=["operator-credentials"])
class CredentialOut(BaseModel):
id: str
learner_ref: str
vc_type: str | None = None
status: str
issued_at: _dt.datetime | None = None
revoked_at: _dt.datetime | None = None
class CredentialListResponse(BaseModel):
credentials: list[CredentialOut]
class OkResponse(BaseModel):
ok: bool = True
id: str
status: str
@router.get("/credentials", response_model=CredentialListResponse)
async def list_credentials(
request: Request,
op: Operator = Depends(current_operator),
) -> CredentialListResponse:
pg_store = await require_pg_store(request)
rows = await pg_store.list_credentials()
creds = [
CredentialOut(
id=str(r["id"]),
learner_ref=r["learner_ref"],
vc_type=r.get("vc_type"),
status=r.get("status", "active"),
issued_at=r.get("issued_at"),
revoked_at=r.get("revoked_at"),
)
for r in rows
]
return CredentialListResponse(credentials=creds)
@router.post("/credentials/{cred_id}/revoke", response_model=OkResponse)
async def revoke_credential(
cred_id: str,
request: Request,
op: Operator = Depends(current_operator),
) -> OkResponse:
pg_store = await require_pg_store(request)
row = await pg_store.get_credential(cred_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="credential not found")
await pg_store.set_credential_status(cred_id, "revoked")
return OkResponse(ok=True, id=cred_id, status="revoked")
__all__ = ["router"]