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>
464 lines
19 KiB
Python
464 lines
19 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
|
|
|
|
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")
|
|
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"
|
|
|
|
|
|
@router.post("/auth/demo/fresh", response_model=AuthOut)
|
|
async def auth_demo_fresh() -> 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)
|
|
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"{persona.display_name} (демо)",
|
|
snils=snils,
|
|
clinic_id="demo",
|
|
doctor_id=fixtures.DOCTOR_ID,
|
|
now=now,
|
|
birth_year=now.year - 34,
|
|
sex="m",
|
|
)
|
|
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)
|
|
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=persona.display_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,
|
|
),
|
|
)
|
|
|
|
|
|
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"}
|