CI / ruff · pytest (pull_request) Failing after 2m33s
- POST /auth/demo/fresh + «Демо: войти как Андрей» on the welcome screen: every sign-in gets a fresh private copy of veteran Andrey (consent, prescription, ЕМИАС record) and lands in the chat — no codes, nobody's crisis or half-done questionnaire leaks into another viewer. - Initial screening switch: SCREENING_ENABLED env default + /demo/settings + a toggle in the demo console; off → after consent straight to the chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
14 KiB
Python
310 lines
14 KiB
Python
"""API routes `/api/v1` (#34 contract → #12 implementation).
|
||
|
||
Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
|
||
Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`.
|
||
Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`,
|
||
`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are
|
||
disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`.
|
||
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
|
||
"""
|
||
|
||
import secrets
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from sqlalchemy import select
|
||
|
||
from app.api import demo as console
|
||
from app.api import doctor_views as views
|
||
from app.api import fixtures, max_notifications
|
||
from app.api.patient_web import router as patient_web_router
|
||
from app.api.questionnaire import router as questionnaire_router
|
||
from app.api.schemas import (
|
||
AckIn,
|
||
AlertOut,
|
||
ClockAdvanceIn,
|
||
ClockOut,
|
||
ConnectPatientIn,
|
||
ConnectPatientOut,
|
||
DemoPersona,
|
||
DialogIn,
|
||
DialogOut,
|
||
MessageOut,
|
||
PatientCard,
|
||
PatientListItem,
|
||
)
|
||
from app.core import clock
|
||
from app.core.privacy import pseudo_id
|
||
from app.db import identity_repo, repo
|
||
from app.db.audit import audit
|
||
from app.db.clinical import Alert, Message, PatientProfile
|
||
from app.db.clinical import Prescription as PrescriptionRow
|
||
from app.db.identity import Patient
|
||
from app.db.session import get_clinical_session, get_identity_session
|
||
from app.dialog.contracts import Event, get_engine
|
||
from app.integrations.base import integrations
|
||
from app.scheduler import jobs as scheduler
|
||
from app.services.anonymous_care import display_name as anonymous_name
|
||
from app.services.monitoring import refresh_status
|
||
|
||
router = APIRouter(prefix="/api/v1")
|
||
router.include_router(max_notifications.router)
|
||
router.include_router(questionnaire_router)
|
||
router.include_router(patient_web_router)
|
||
|
||
DEFAULT_CLINIC = "clinic-demo"
|
||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
|
||
CODE_LEN = 8
|
||
|
||
|
||
async def _patients(tokens: list[str]) -> dict[str, Patient]:
|
||
"""Identity rows for display name / age only (never put SNILS or phone into a response)."""
|
||
if not tokens:
|
||
return {}
|
||
async with get_identity_session() as s:
|
||
rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all()
|
||
return {p.token: p for p in rows}
|
||
|
||
|
||
def _name(patients: dict[str, Patient], token: str) -> str:
|
||
p = patients.get(token)
|
||
return p.display_name if p else anonymous_name(token)
|
||
|
||
|
||
# ------------------------------------------------------------------ doctor dashboard
|
||
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
|
||
async def list_patients(doctor_id: str) -> list[PatientListItem]:
|
||
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
|
||
now = clock.now()
|
||
async with get_clinical_session() as s:
|
||
profiles = await repo.patients_of_doctor(s, doctor_id)
|
||
data = [await views.load_patient_data(s, p) for p in profiles]
|
||
await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now)
|
||
patients = await _patients([p.token for p in profiles])
|
||
items = [views.list_item(d, patients.get(d.profile.token), now) for d in data]
|
||
return sorted(items, key=views.sort_key)
|
||
|
||
|
||
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
|
||
async def patient_card(token: str, doctor_id: str) -> PatientCard:
|
||
"""Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
|
||
now = clock.now()
|
||
patient = (await _patients([token])).get(token)
|
||
async with get_clinical_session() as s:
|
||
profile = await repo.get_profile(s, token)
|
||
if profile is None:
|
||
raise HTTPException(404, "patient not found")
|
||
if profile.doctor_id != doctor_id:
|
||
await audit(s, actor=doctor_id, action="view_patient_denied", token=token, ts=now)
|
||
await s.commit()
|
||
raise HTTPException(403, "not your patient")
|
||
data = await views.load_patient_data(s, profile)
|
||
card = await views.build_card(s, data, patient, now)
|
||
await audit(s, actor=doctor_id, action="view_patient", token=token, ts=now)
|
||
# Summary after the commit: the (possibly slow) LLM call must not hold the DB transaction.
|
||
from app.llm.summary import build_summary # summary imports app.api.schemas; avoid an import cycle
|
||
|
||
card.summary = await build_summary(card, today=now.date())
|
||
return card
|
||
|
||
|
||
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
|
||
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
|
||
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
|
||
async with get_clinical_session() as s:
|
||
tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id)))
|
||
stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id)
|
||
if open_only:
|
||
stmt = stmt.where(Alert.acked_at.is_(None))
|
||
alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
|
||
if alerts:
|
||
await audit(
|
||
s,
|
||
actor=doctor_id,
|
||
action="view_alerts",
|
||
token=None,
|
||
details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)},
|
||
ts=clock.now(),
|
||
)
|
||
patients = await _patients(sorted({a.token for a in alerts}))
|
||
return [views.alert_out(a, _name(patients, a.token)) for a in alerts]
|
||
|
||
|
||
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
|
||
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
|
||
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
|
||
now = clock.now()
|
||
async with get_clinical_session() as s:
|
||
alert = await s.get(Alert, alert_id)
|
||
if alert is None:
|
||
raise HTTPException(404, "alert not found")
|
||
profile = await repo.get_profile(s, alert.token)
|
||
if profile is None or profile.doctor_id != body.doctor_id:
|
||
raise HTTPException(403, "not your patient")
|
||
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
|
||
await refresh_status(s, alert.token)
|
||
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
|
||
out = views.alert_out(alert, "")
|
||
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
|
||
if alert.level == "acute" and not open_acute:
|
||
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
|
||
if replies:
|
||
await integrations.outbox.push(alert.token, replies)
|
||
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
|
||
|
||
|
||
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
|
||
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
|
||
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
|
||
now = clock.now()
|
||
try:
|
||
token = pseudo_id(body.snils)
|
||
except ValueError as exc:
|
||
raise HTTPException(422, "SNILS must contain 11 digits") from exc
|
||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
|
||
async with get_identity_session() as ids:
|
||
if await identity_repo.get_patient(ids, token) is not None:
|
||
raise HTTPException(409, "patient already connected")
|
||
doctor = await identity_repo.get_doctor(ids, body.doctor_id)
|
||
await identity_repo.create_patient(
|
||
ids,
|
||
token=token,
|
||
display_name=body.display_name,
|
||
snils=body.snils,
|
||
clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC,
|
||
doctor_id=body.doctor_id,
|
||
now=now,
|
||
birth_year=body.birth_year,
|
||
sex=body.sex,
|
||
)
|
||
await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now)
|
||
# Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too.
|
||
async with get_clinical_session() as s:
|
||
await repo.upsert_profile(
|
||
s,
|
||
token=token,
|
||
doctor_id=body.doctor_id,
|
||
modules=list(body.modules),
|
||
therapy_start=now.date(),
|
||
next_visit=body.next_visit,
|
||
birth_year=body.birth_year,
|
||
sex=body.sex,
|
||
)
|
||
schedule: dict[str, list[str]] = {"times": list(body.times)}
|
||
if body.instructions:
|
||
schedule["instructions"] = [body.instructions]
|
||
s.add(
|
||
PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date())
|
||
)
|
||
await audit(
|
||
s,
|
||
actor=body.doctor_id,
|
||
action="connect_patient",
|
||
token=token,
|
||
details={"modules": list(body.modules), "drug": body.drug},
|
||
ts=now,
|
||
)
|
||
return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code))
|
||
|
||
|
||
# ------------------------------------------------------------------ patient dialog (web console / any channel)
|
||
async def _require_patient(token: str) -> None:
|
||
async with get_clinical_session() as s:
|
||
if await repo.get_profile(s, token) is None:
|
||
raise HTTPException(404, "patient not found")
|
||
|
||
|
||
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
|
||
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
|
||
await _require_patient(token)
|
||
return DialogOut(replies=await get_engine().handle(token, body))
|
||
|
||
|
||
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
|
||
async def dialog_outbox(token: str) -> DialogOut:
|
||
"""System-initiated replies (reminders, check-ins) waiting for the web console."""
|
||
box = console.memory_outbox()
|
||
return DialogOut(replies=box.drain(token) if box is not None else [])
|
||
|
||
|
||
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
|
||
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
|
||
"""Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages."""
|
||
async with get_clinical_session() as s:
|
||
stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc())
|
||
rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all()
|
||
return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type]
|
||
|
||
|
||
# ------------------------------------------------------------------ demo controls (ADR-005)
|
||
def _clock_out() -> ClockOut:
|
||
return ClockOut(now=clock.now(), virtual=clock._virtual(), offset_days=clock.offset().total_seconds() / 86400)
|
||
|
||
|
||
@router.get("/demo/clock", response_model=ClockOut, tags=["demo"])
|
||
async def get_clock() -> ClockOut:
|
||
return _clock_out()
|
||
|
||
|
||
@router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"])
|
||
async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
|
||
"""Moves the virtual clock and replays the scheduler up to the new time (#13)."""
|
||
clock.advance(days=body.days, hours=body.hours)
|
||
await scheduler.run_until_now()
|
||
return _clock_out()
|
||
|
||
|
||
@router.post("/demo/reset", tags=["demo"])
|
||
async def demo_reset() -> dict[str, str]:
|
||
"""Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18)."""
|
||
clock.reset()
|
||
seeded = await console.reset_demo()
|
||
return {"status": "ok", "seed": "reset" if seeded else "skipped"}
|
||
|
||
|
||
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
|
||
async def demo_personas() -> list[DemoPersona]:
|
||
"""Patients with a scripted scenario `data/synthetic/<slug>.jsonl`; the demo cabinet's doctor first."""
|
||
scenarios = console.scenario_files()
|
||
if not scenarios:
|
||
return []
|
||
async with get_identity_session() as ids:
|
||
patients = (await ids.scalars(select(Patient))).all()
|
||
scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios]
|
||
if not scripted:
|
||
return []
|
||
today = clock.today()
|
||
async with get_clinical_session() as s:
|
||
profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted}
|
||
out = []
|
||
for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)):
|
||
profile = profiles.get(p.token)
|
||
start = profile.therapy_start if profile else None
|
||
out.append(
|
||
DemoPersona(
|
||
token=p.token,
|
||
display_name=p.display_name,
|
||
therapy_day=(today - start).days if start else 0,
|
||
scenario=path,
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
@router.get("/demo/settings", tags=["demo"])
|
||
async def get_demo_settings() -> dict[str, bool]:
|
||
"""Presentation switches: `screening_enabled` — initial questionnaires after consent (off → straight to chat)."""
|
||
from app.core import demo_settings
|
||
|
||
async with get_clinical_session() as s:
|
||
return await demo_settings.get_all(s)
|
||
|
||
|
||
@router.post("/demo/settings", tags=["demo"])
|
||
async def set_demo_settings(body: dict[str, bool]) -> dict[str, bool]:
|
||
from app.core import demo_settings
|
||
|
||
async with get_clinical_session() as s:
|
||
if demo_settings.SCREENING in body:
|
||
await demo_settings.set_screening(s, bool(body[demo_settings.SCREENING]))
|
||
return await demo_settings.get_all(s)
|