CI / ruff · pytest (pull_request) Successful in 2m17s
GET /api/v1/me/{token}/garden computes the garden on the fly from taken doses,
check-ins/diary and finished questionnaires (no new table). Rules and texts in
config/garden.yaml: per-day caps, points only add up, answers never count.
Refs #118
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
489 lines
20 KiB
Python
489 lines
20 KiB
Python
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
|
||
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
|
||
|
||
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
|
||
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
|
||
|
||
Two ways in:
|
||
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
|
||
patient is linked to the attending doctor, who sees the data on the dashboard.
|
||
- **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
|
||
Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
|
||
Consent is kept in the dialog state. Existing doctor-less profiles require explicit care opt-in.
|
||
|
||
The token returned here is the session credential of the prototype. Production needs short-lived sessions
|
||
issued by the server instead of handing out the pseudonymous token itself.
|
||
"""
|
||
|
||
import logging
|
||
import secrets
|
||
from collections import defaultdict
|
||
from datetime import date, datetime, time, timedelta
|
||
from typing import Any, Literal
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
from sqlalchemy import select
|
||
|
||
from app.api import fixtures
|
||
from app.api.schemas import DemoPersona
|
||
from app.core import clock
|
||
from app.core.instrument_catalog import Catalog, catalog
|
||
from app.core.intake_questions import DIARY_KINDS
|
||
from app.core.intake_runtime import intake_enabled, load_runtime
|
||
from app.core.interpretation import texts as results_texts
|
||
from app.core.meds_reference import find_drug, indication_for, load_reference
|
||
from app.core.safety import CrisisResource, crisis_reply
|
||
from app.db import identity_repo, repo
|
||
from app.db.audit import audit
|
||
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||
from app.db.identity import TOKEN_LEN
|
||
from app.db.intake import QuestionnaireRecord
|
||
from app.db.session import get_clinical_session, get_identity_session
|
||
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
||
from app.services import anonymous_care
|
||
from app.services.garden import Garden, garden_for
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(tags=["patient-web"])
|
||
_emias = EmiasMock()
|
||
|
||
|
||
class AuthOut(BaseModel):
|
||
token: str
|
||
mode: Literal["emias", "anonymous"]
|
||
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
|
||
|
||
|
||
class EmiasCodeIn(BaseModel):
|
||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
|
||
|
||
|
||
class AnonymousIn(BaseModel):
|
||
model_config = ConfigDict(extra="forbid")
|
||
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
|
||
|
||
|
||
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
|
||
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
|
||
|
||
|
||
class EmiasDemoIn(BaseModel):
|
||
model_config = ConfigDict(extra="forbid")
|
||
persona_token: str = Field(min_length=1, max_length=64)
|
||
|
||
|
||
async def _attach_doctor(token: str, doctor_id: str) -> None:
|
||
async with get_clinical_session() as s:
|
||
if await repo.get_profile(s, token) is None:
|
||
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
||
|
||
|
||
async def _seed_demo_persona(token: str, source: str | None = None) -> None:
|
||
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
|
||
|
||
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
|
||
doctor's card tell the same story.
|
||
"""
|
||
card = fixtures.card(source or token)
|
||
item = next((p for p in fixtures.PATIENTS if p.token == (source or token)), None)
|
||
if card is None or item is None:
|
||
return
|
||
now = clock.now()
|
||
today = now.date()
|
||
async with get_clinical_session() as s:
|
||
if await repo.get_profile(s, token) is None:
|
||
await repo.upsert_profile(
|
||
s,
|
||
token=token,
|
||
doctor_id=fixtures.DOCTOR_ID,
|
||
modules=list(item.modules),
|
||
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
|
||
next_visit=today + timedelta(days=15),
|
||
)
|
||
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
|
||
return
|
||
for p in card.prescriptions:
|
||
rx = Prescription(
|
||
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
|
||
)
|
||
s.add(rx)
|
||
await s.flush()
|
||
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
|
||
for d in card.doses:
|
||
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
|
||
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
|
||
for t in p.schedule.get("times", []):
|
||
hh, mm = (int(x) for x in t.split(":"))
|
||
s.add(
|
||
Dose(
|
||
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
|
||
)
|
||
)
|
||
|
||
|
||
@router.post("/auth/emias", response_model=AuthOut)
|
||
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
||
person = await _emias.verify(body.code)
|
||
if person is None:
|
||
log.info("web auth: emias code rejected")
|
||
raise HTTPException(401, "code not accepted")
|
||
await _attach_doctor(person.token, person.doctor_id)
|
||
log.info("web auth: emias tok=%s", person.token[:6])
|
||
return AuthOut(token=person.token, mode="emias")
|
||
|
||
|
||
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
|
||
async def patient_demo_personas() -> list[DemoPersona]:
|
||
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
|
||
return fixtures.DEMO_PERSONAS
|
||
|
||
|
||
@router.post("/auth/emias/demo", response_model=AuthOut)
|
||
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`)."""
|
||
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
||
if persona is None:
|
||
raise HTTPException(404, "persona not found")
|
||
if persona.token == DEMO_SOURCE:
|
||
# Every visitor gets a private copy (never the shared account); this entry starts from consent and the
|
||
# initial questionnaires (subject to the screening switch), unlike the one-click chat demo.
|
||
return await _fresh_demo_copy(start_in_chat=False)
|
||
await _seed_demo_persona(persona.token)
|
||
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
||
|
||
|
||
DEMO_SOURCE = "tok-andrey"
|
||
ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита)
|
||
|
||
|
||
@router.post("/auth/demo/fresh", response_model=AuthOut)
|
||
async def auth_demo_fresh() -> AuthOut:
|
||
return await _fresh_demo_copy(start_in_chat=True)
|
||
|
||
|
||
async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut:
|
||
"""Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription
|
||
and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into
|
||
another viewer's demo. Synthetic data only."""
|
||
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
|
||
now = clock.now()
|
||
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
|
||
name = persona.display_name if not start_in_chat else ONE_CLICK_NAME
|
||
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
|
||
async with get_identity_session() as s:
|
||
await identity_repo.create_patient(
|
||
s,
|
||
token=token,
|
||
display_name=f"{name} (демо)",
|
||
snils=snils,
|
||
clinic_id="demo",
|
||
doctor_id=fixtures.DOCTOR_ID,
|
||
now=now,
|
||
birth_year=now.year - 34,
|
||
sex="m",
|
||
)
|
||
if start_in_chat:
|
||
await identity_repo.record_consent(s, token=token, version="demo", now=now)
|
||
await _seed_demo_persona(token, source=DEMO_SOURCE)
|
||
async with get_clinical_session() as s:
|
||
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
|
||
if start_in_chat:
|
||
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
|
||
await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now)
|
||
log.info("web auth: demo fresh tok=%s", token[:14])
|
||
return AuthOut(token=token, mode="emias", display_name=name)
|
||
|
||
|
||
@router.post("/auth/anonymous", response_model=AuthOut)
|
||
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
|
||
"""Anonymous sign-in: a fresh random token; care without an identity record.
|
||
|
||
The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
|
||
no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
|
||
"""
|
||
token = secrets.token_hex(TOKEN_LEN // 2)
|
||
focus = body.focus if body is not None else None
|
||
async with get_clinical_session() as s:
|
||
await repo.upsert_profile(
|
||
s, token=token, doctor_id=anonymous_care.doctor_id(), modules=[_FOCUS_MODULE[focus]] if focus else []
|
||
)
|
||
log.info("web auth: anonymous tok=%s", token[:6])
|
||
return AuthOut(token=token, mode="anonymous")
|
||
|
||
|
||
@router.get("/patient/capabilities")
|
||
async def patient_capabilities() -> dict[str, bool]:
|
||
"""Expose availability without leaking clinical policy or server configuration."""
|
||
return {"primary_screening": intake_enabled()}
|
||
|
||
|
||
@router.post("/me/{token}/doctor/connect")
|
||
async def connect_anonymous_doctor(token: str) -> dict[str, bool]:
|
||
"""Explicit opt-in for old doctor-less profiles; never reassign an existing doctor."""
|
||
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 not profile.doctor_id:
|
||
profile.doctor_id = anonymous_care.doctor_id()
|
||
await audit(s, actor=token, action="anonymous_care_opt_in", token=token, ts=clock.now())
|
||
return {"has_doctor": True}
|
||
|
||
|
||
class DoseItem(BaseModel):
|
||
id: int
|
||
drug: str
|
||
dose: str
|
||
time: str # HH:MM, patient's local time
|
||
status: Literal["taken", "missed", "skipped", "pending"]
|
||
|
||
|
||
class WeekDay(BaseModel):
|
||
day: date
|
||
status: Literal["taken", "missed", "pending", "none"]
|
||
|
||
|
||
class PrescriptionItem(BaseModel):
|
||
drug: str
|
||
dose: str
|
||
times: list[str]
|
||
week: list[WeekDay] # Mon..Sun of the current week
|
||
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
|
||
indication: Literal["depression", "ptsd"] | None = None
|
||
start_date: date | None = None
|
||
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
|
||
|
||
|
||
class Questionnaires(BaseModel):
|
||
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
|
||
|
||
enabled: bool
|
||
intake: Literal["none", "active", "paused", "complete", "review"]
|
||
diary_done: bool # both parts of today's diary
|
||
diary_next: Literal["morning", "evening"] | None = None # the part still open today
|
||
|
||
|
||
class TodayOut(BaseModel):
|
||
today: date
|
||
now: str # HH:MM on the (demo) clock — the client never uses its own clock
|
||
has_doctor: bool
|
||
checkin_done: bool
|
||
next_visit: date | None
|
||
doses: list[DoseItem]
|
||
prescriptions: list[PrescriptionItem]
|
||
questionnaires: Questionnaires
|
||
|
||
|
||
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
|
||
intake = [r for r in records if r.kind == "intake"]
|
||
if any(r.status == "complete" for r in intake):
|
||
return "complete"
|
||
if not intake:
|
||
return "none"
|
||
last = max(intake, key=lambda r: r.started_at).status
|
||
if last in ("active", "paused"):
|
||
return last
|
||
return "review" if last in ("review_required", "interrupted") else "none"
|
||
|
||
|
||
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
|
||
if not statuses:
|
||
return "none"
|
||
if any(s in ("missed", "skipped") for s in statuses):
|
||
return "missed"
|
||
return "taken" if all(s == "taken" for s in statuses) else "pending"
|
||
|
||
|
||
@router.get("/me/{token}/today", response_model=TodayOut)
|
||
async def my_today(token: str) -> TodayOut:
|
||
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
|
||
|
||
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
|
||
"""
|
||
now = clock.now()
|
||
today = now.date()
|
||
week_start = today - timedelta(days=today.weekday())
|
||
async with get_clinical_session() as s:
|
||
profile = await repo.get_profile(s, token)
|
||
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
|
||
doses = (
|
||
await s.scalars(
|
||
select(Dose)
|
||
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
|
||
.order_by(Dose.planned_at)
|
||
)
|
||
).all()
|
||
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
|
||
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
|
||
|
||
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
|
||
today_doses: list[DoseItem] = []
|
||
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
|
||
for d in doses:
|
||
local = d.planned_at.astimezone(now.tzinfo)
|
||
marks[(d.prescription_id, local.date())].append(d.status)
|
||
rx = by_rx.get(d.prescription_id)
|
||
if local.date() == today and rx is not None:
|
||
today_doses.append(
|
||
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
|
||
)
|
||
week = [week_start + timedelta(days=i) for i in range(7)]
|
||
modules = list(profile.modules or []) if profile else []
|
||
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
|
||
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
|
||
diary_done = not open_parts
|
||
|
||
def item(p: Prescription) -> PrescriptionItem:
|
||
ref = find_drug(p.drug)
|
||
return PrescriptionItem(
|
||
drug=p.drug,
|
||
dose=p.dose,
|
||
times=list((p.schedule or {}).get("times", [])),
|
||
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
|
||
ref_id=ref["id"] if ref else None,
|
||
indication=indication_for(modules, ref) if ref else None,
|
||
start_date=p.start_date,
|
||
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
|
||
)
|
||
|
||
return TodayOut(
|
||
today=today,
|
||
now=now.strftime("%H:%M"),
|
||
has_doctor=profile is not None and bool(profile.doctor_id),
|
||
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
|
||
next_visit=profile.next_visit if profile else None,
|
||
doses=today_doses,
|
||
prescriptions=[
|
||
item(p)
|
||
for p in prescriptions
|
||
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||
],
|
||
questionnaires=Questionnaires(
|
||
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
|
||
enabled=intake_enabled(),
|
||
intake=_intake_state(records),
|
||
diary_done=diary_done,
|
||
diary_next=open_parts[0] if open_parts else None,
|
||
),
|
||
)
|
||
|
||
|
||
@router.get("/me/{token}/garden", response_model=Garden)
|
||
async def my_garden(token: str) -> Garden:
|
||
"""The patient's garden (#118): grows from medication marks, check-ins/diary and finished questionnaires.
|
||
|
||
Never shrinks and never depends on answers. Seen only by the patient — not on the dashboard, not in summaries.
|
||
"""
|
||
async with get_clinical_session() as s:
|
||
if await repo.get_profile(s, token) is None:
|
||
raise HTTPException(404, "patient not found")
|
||
return await garden_for(s, token)
|
||
|
||
|
||
class ResultFinding(BaseModel):
|
||
code: str
|
||
title: str
|
||
|
||
|
||
class Conclusion(BaseModel):
|
||
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
|
||
|
||
day: date
|
||
title: str
|
||
lines: list[str]
|
||
findings: list[ResultFinding]
|
||
modules: list[str]
|
||
disclaimer: str
|
||
|
||
|
||
class HistoryItem(BaseModel):
|
||
id: str
|
||
kind: str
|
||
kind_title: str
|
||
status: str
|
||
status_title: str
|
||
day: date
|
||
started_at: datetime
|
||
completed_at: datetime | None
|
||
title: str | None # interpretation shown to the patient when the form was finished
|
||
lines: list[str]
|
||
|
||
|
||
class ResultsOut(BaseModel):
|
||
conclusion: Conclusion | None
|
||
history: list[HistoryItem]
|
||
|
||
|
||
@router.get("/me/{token}/results", response_model=ResultsOut)
|
||
async def my_results(token: str) -> ResultsOut:
|
||
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
|
||
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
|
||
async with get_clinical_session() as s:
|
||
records = list(
|
||
(
|
||
await s.scalars(
|
||
select(QuestionnaireRecord)
|
||
.where(QuestionnaireRecord.token == token)
|
||
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
|
||
)
|
||
).all()
|
||
)
|
||
t = results_texts()
|
||
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
|
||
conclusion = None
|
||
if intake and intake.details.get("interpretation"):
|
||
i = intake.details["interpretation"]
|
||
conclusion = Conclusion(
|
||
day=intake.day,
|
||
title=i["title"],
|
||
lines=i["lines"],
|
||
findings=[ResultFinding(**f) for f in i.get("findings", [])],
|
||
modules=i.get("modules", []),
|
||
disclaimer=i.get("disclaimer", t["disclaimer"]),
|
||
)
|
||
history = [
|
||
HistoryItem(
|
||
id=r.id,
|
||
kind=r.kind,
|
||
kind_title=t["kinds"].get(r.kind, r.kind),
|
||
status=r.status,
|
||
status_title=t["status"].get(r.status, r.status),
|
||
day=r.day,
|
||
started_at=r.started_at,
|
||
completed_at=r.completed_at,
|
||
title=(r.details.get("interpretation") or {}).get("title"),
|
||
lines=(r.details.get("interpretation") or {}).get("lines", []),
|
||
)
|
||
for r in records
|
||
]
|
||
return ResultsOut(conclusion=conclusion, history=history)
|
||
|
||
|
||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||
async def crisis_resources() -> list[CrisisResource]:
|
||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||
return crisis_reply([], "patient").resources
|
||
|
||
|
||
@router.get("/instruments/catalog", response_model=Catalog)
|
||
async def instruments_catalog() -> Catalog:
|
||
"""Form, position and recall period of every questionnaire item, for the question screen header."""
|
||
return catalog()
|
||
|
||
|
||
@router.get("/meds/reference")
|
||
async def meds_reference() -> dict[str, Any]:
|
||
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
|
||
|
||
The internal rule parameters stay on the server.
|
||
"""
|
||
ref = load_reference()
|
||
return {k: v for k, v in ref.items() if k != "rules"}
|