"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII.""" from collections.abc import AsyncIterator from datetime import datetime, timedelta from types import SimpleNamespace import httpx import pytest from sqlalchemy import select from app.api import doctor_views from app.config import Settings from app.core import clock from app.core.privacy import pseudo_id from app.core.types import AlertLevel from app.db import identity_repo, repo, session from app.db.audit import AuditLog from app.db.bootstrap import init_db from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations from app.main import app MEM = "sqlite+aiosqlite:///:memory:" DOCTOR = "doc-smirnova" OTHER = "doc-other" DAY0 = datetime(2026, 10, 5, 9, 0) # Synthetic patients: token suffix → (name, snils, phone, doctor) ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef") PEOPLE = { ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR), DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR), OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR), PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR), IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR), STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER), } @pytest.fixture async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]: monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) monkeypatch.setattr(integrations, "notifier", MemoryNotifier()) monkeypatch.setattr(integrations, "outbox", MemoryOutbox()) clock.set_now(DAY0 + timedelta(days=13)) await session.configure(identity_url=MEM, clinical_url=MEM) await init_db() await seed() # No lifespan: the scheduler and the Telegram bot are not started. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: yield c clock.register_persistence(None, None) clock.reset() await session.configure() async def seed() -> None: now = clock.now() start = (DAY0).date() async with session.get_identity_session() as ids: for token, (name, snils, phone, doctor) in PEOPLE.items(): await identity_repo.create_patient( ids, token=token, display_name=name, snils=snils, phone=phone, clinic_id="clinic-1", doctor_id=doctor, now=now, birth_year=1990, sex="m", ) async with session.get_clinical_session() as s: for token, (_, _, _, doctor) in PEOPLE.items(): await repo.upsert_profile( s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m" ) s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})) await s.flush() rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()} # Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan. await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now) await repo.add_scale_result( s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7) ) await repo.add_scale_result( s, token=ANDREY, scale="phq9", total=18, severity="mod_severe", complete=True, ts=now - timedelta(hours=2), scheduled=False, item9=2, ) await repo.add_scale_result( s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13) ) for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]): await repo.upsert_checkin( s, token=ANDREY, day=now.date() - timedelta(days=2 - i), now=now, mood=mood, sleep_bucket=sleep, alcohol_bucket=alcohol, med_taken=True, ) s.add( SafetyPlan( token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now ) ) # Dmitry: high alert (yellow). Olga: info only (grey). await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now) await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now) # Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens. for d in range(7): planned = now - timedelta(days=d, hours=1) s.add( Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken") ) s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken")) await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5) # Foreign doctor's patient with an open alert: must never show up for DOCTOR. await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now) await repo.set_dialog_state( s, token=ANDREY, state="crisis", data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}}, now=now, ) async def audit_rows(action: str) -> list[AuditLog]: async with session.get_clinical_session() as s: return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all()) async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None: rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json() assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA] assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"] andrey, pavel, irina = rows[0], rows[3], rows[4] assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36 assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1 assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up") assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13 assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale assert rows[1]["last_event"] == "PCL-5 ≥ 50" factors = " ".join(andrey["discontinuation_risk"]["factors"]) assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01) assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0 assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent assert pavel["discontinuation_risk"]["level"] == "medium" assert len(await audit_rows("view_patient_list")) == 1 async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None: r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR}) assert r.status_code == 200 card = r.json() metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]} assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"] assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red" assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]} assert card["summary"] rows = await audit_rows("view_patient") assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None: r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER}) assert r.status_code == 403 assert await audit_rows("view_patient") == [] assert len(await audit_rows("view_patient_denied")) == 1 assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404 async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None: alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json() assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient assert all(a["patient_name"] for a in alerts) top = max(a["id"] for a in alerts) assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == [] assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None: alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json() acute = next(a for a in alerts if a["token"] == ANDREY) assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403 r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR}) assert r.status_code == 200 out = r.json() assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С." async with session.get_clinical_session() as s: state = await repo.get_dialog_state(s, ANDREY) profile = await s.get(PatientProfile, ANDREY) assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False assert profile is not None and profile.status_color == "green" assert isinstance(integrations.outbox, MemoryOutbox) assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console assert len(await audit_rows("ack_alert")) == 1 rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json() assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green" async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None: body = { "doctor_id": DOCTOR, "display_name": "Сергей Т.", "birth_year": 1988, "sex": "m", "snils": "123-456-789 01", "drug": "сертралин", "dose": "50 мг", "times": ["08:00", "20:00"], "instructions": "после еды", "modules": ["veteran", "depression"], } r = await client.post("/api/v1/patients/connect", json=body) assert r.status_code == 200 out = r.json() assert out["token"] == pseudo_id("12345678901") assert out["deep_link"] == f"/console?code={out['code']}" assert "12345678901" not in r.text and "123-456-789" not in r.text async with session.get_identity_session() as ids: assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"] async with session.get_clinical_session() as s: profile = await repo.get_profile(s, out["token"]) rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"])) assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"] assert len(await audit_rows("connect_patient")) == 1 card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json() assert card["prescriptions"][0]["instructions"] == "после еды" assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green" assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409 async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None: texts = [ (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text, (await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text, (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text, ] for _, snils, phone, _ in PEOPLE.values(): for text in texts: assert snils not in text and phone not in text and phone[1:] not in text def test_risk_index_all_factors_capped() -> None: now = datetime(2026, 10, 18, 12, 0).astimezone() days = [now.date() - timedelta(days=i) for i in range(4)] checkins = [ SimpleNamespace( day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more" ) for d in days ] data = doctor_views.PatientData( profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type] checkins=checkins, # type: ignore[arg-type] results=[], doses=[], prescriptions=[], alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item] ) risk = doctor_views.discontinuation_risk(data, now) assert risk.score == 100 and risk.level == "high" assert risk.factors == [ "пропуски приёма 4/7 дн.", "побочные эффекты 4 дн. подряд", "хотел бросить (день 13)", "не разобран сигнал «хочет бросить»", "настроение ≤ 3 4 дн. подряд", "алкоголь 4 дн. из 7", ]