"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`). 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 and nothing else — no identity record, no doctor. The engine supports tokens without an identity record (consent is kept in the dialog state), so no personal data is stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor. 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 Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from app.api import fixtures from app.core import clock from app.core.safety import CrisisResource, crisis_reply from app.db import repo from app.db.clinical import DailyCheckin, Dose, Prescription from app.db.identity import TOKEN_LEN from app.db.session import get_clinical_session from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock 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 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) -> 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(token) item = next((p for p in fixtures.PATIENTS if p.token == 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=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=today) s.add(rx) await s.flush() 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.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 (`/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) @router.post("/auth/anonymous", response_model=AuthOut) async def auth_anonymous() -> AuthOut: """Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere.""" token = secrets.token_hex(TOKEN_LEN // 2) log.info("web auth: anonymous tok=%s", token[:6]) return AuthOut(token=token, mode="anonymous") 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 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] 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)) 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)] return TodayOut( today=today, now=now.strftime("%H:%M"), has_doctor=profile is not None, checkin_done=checkin is not None and checkin.mood is not None, next_visit=profile.next_visit if profile else None, doses=today_doses, prescriptions=[ 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], ) 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) ], ) @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