CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable. Refs #52
99 lines
3.9 KiB
Python
99 lines
3.9 KiB
Python
"""Audited questionnaire review for the synthetic prototype.
|
|
|
|
Like the existing API, doctor_id is supplied by the caller; production must put
|
|
these routes behind authenticated staff identity. No clinical data cross into identity.
|
|
"""
|
|
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from app.core import clock
|
|
from app.db import repo
|
|
from app.db.audit import audit
|
|
from app.db.intake import QuestionnaireRecord
|
|
from app.db.session import get_clinical_session
|
|
from app.services import questionnaire as service
|
|
|
|
router = APIRouter(tags=["questionnaire-review"])
|
|
|
|
|
|
class ReviewIn(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
doctor_id: str = Field(min_length=1)
|
|
assessment_performed: Literal[True]
|
|
decision: Literal["resume", "keep_hold"]
|
|
reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$")
|
|
|
|
|
|
async def require_doctor(session, token, doctor_id):
|
|
profile = await repo.get_profile(session, token)
|
|
if profile is None or profile.doctor_id != doctor_id:
|
|
raise HTTPException(404, "patient not found")
|
|
|
|
|
|
@router.get("/patients/{token}/questionnaires")
|
|
async def history(token: str, doctor_id: str):
|
|
async with get_clinical_session() as session:
|
|
await require_doctor(session, token, doctor_id)
|
|
rows = await service.records(session, token)
|
|
await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now())
|
|
return [
|
|
{
|
|
"id": r.id,
|
|
"kind": r.kind,
|
|
"status": r.status,
|
|
"version": r.version,
|
|
"policy_version": r.policy_version,
|
|
"started_at": r.started_at,
|
|
"completed_at": r.completed_at,
|
|
"answers": r.answers,
|
|
"details": r.details,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/patients/{token}/questionnaires/{record_id}/review")
|
|
async def review(token: str, record_id: str, body: ReviewIn):
|
|
now = clock.now()
|
|
async with get_clinical_session() as session:
|
|
await service.lock_patient(session, token)
|
|
await require_doctor(session, token, body.doctor_id)
|
|
state = await repo.get_dialog_state(session, token)
|
|
row = await session.get(QuestionnaireRecord, record_id)
|
|
if row is None or row.token != token:
|
|
raise HTTPException(404, "record not found")
|
|
if not state or state.data.get("consent") is not True:
|
|
raise HTTPException(409, "consent required")
|
|
if row.status not in ("review_required", "interrupted"):
|
|
raise HTTPException(409, "record does not require review")
|
|
if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"):
|
|
raise HTTPException(409, "close the acute episode after assessment before resuming routine work")
|
|
detail = {
|
|
"doctor_id": body.doctor_id,
|
|
"decision": body.decision,
|
|
"reference": body.reference,
|
|
"at": now.isoformat(),
|
|
}
|
|
row.details = {**row.details, "clinician_review": detail}
|
|
if body.decision == "resume":
|
|
row.status = "reviewed"
|
|
remaining = [
|
|
r
|
|
for r in await service.records(session, token)
|
|
if r.id != row.id and r.status in ("review_required", "interrupted")
|
|
]
|
|
if not remaining:
|
|
state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None}
|
|
await audit(
|
|
session,
|
|
actor=body.doctor_id,
|
|
action="review_questionnaire",
|
|
token=token,
|
|
details={"record_id": row.id, "decision": body.decision, "reference": body.reference},
|
|
ts=now,
|
|
)
|
|
return {"id": row.id, "status": row.status, "review": detail}
|