CI / ruff · pytest (pull_request) Successful in 1m3s
- app/scheduler/demo.py: run_until(now) replays due and fresh events in time order: dose rows + med_reminder, missed after 2 h without an answer → monitoring, daily_checkin, scale_due by the config/modules.yaml schedules, daily monitoring pass (silence, missed doses) → reach_out/scale_due - idempotent via scheduler_events (unique token+key); stale events are not sent; no contact without consent or during an open crisis - config/scheduler.yaml: times and windows; runs while the questionnaire release is off - POST /demo/clock/advance replays the scheduler up to the new time Refs #13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
5.0 KiB
Python
122 lines
5.0 KiB
Python
"""API routes `/api/v1` (#34 stubs → #12 implementation).
|
||
|
||
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately.
|
||
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract).
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
from app.api import fixtures
|
||
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.dialog.contracts import get_engine
|
||
from app.integrations.base import MemoryOutbox, integrations
|
||
from app.scheduler import jobs as scheduler
|
||
|
||
router = APIRouter(prefix="/api/v1")
|
||
router.include_router(questionnaire_router)
|
||
|
||
|
||
# ------------------------------------------------------------------ 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."""
|
||
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
|
||
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score))
|
||
|
||
|
||
@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` (#12)."""
|
||
card = fixtures.card(token)
|
||
if card is None:
|
||
raise HTTPException(404, "patient not found")
|
||
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."""
|
||
alerts = [a for a in fixtures.all_alerts() if a.id > since_id]
|
||
return [a for a in alerts if a.acked_at is None] if open_only else 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."""
|
||
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None)
|
||
if alert is None:
|
||
raise HTTPException(404, "alert not found")
|
||
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
|
||
|
||
|
||
@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."""
|
||
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001")
|
||
|
||
|
||
# ------------------------------------------------------------------ patient dialog (web console / any channel)
|
||
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
|
||
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
|
||
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 = integrations.outbox
|
||
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else [])
|
||
|
||
|
||
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
|
||
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
|
||
return []
|
||
|
||
|
||
# ------------------------------------------------------------------ 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.reset()
|
||
return {"status": "ok"}
|
||
|
||
|
||
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
|
||
async def demo_personas() -> list[DemoPersona]:
|
||
return [
|
||
DemoPersona(
|
||
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
|
||
)
|
||
]
|