Files
Pavel Eliseev c42ef621e5
CI / ruff · pytest (pull_request) Successful in 55s
feat(questionnaire): integrate revised intake and follow-up monitoring
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
2026-10-03 18:19:34 +03:00

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}