feat(dashboard): кабинет врача — красные зоны, карточка, живые алерты, подключение пациента #70
@@ -0,0 +1,18 @@
|
||||
# Dashboard-only Streamlit settings (doctor dashboard + demo console).
|
||||
[theme]
|
||||
base = "light"
|
||||
primaryColor = "#2A78D6"
|
||||
backgroundColor = "#F6F7F9"
|
||||
secondaryBackgroundColor = "#FFFFFF"
|
||||
textColor = "#1B1F24"
|
||||
borderColor = "#E3E6EA"
|
||||
baseRadius = "medium"
|
||||
# Falls back to the system sans-serif when offline (ADR-004).
|
||||
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif"
|
||||
headingFontWeights = 700
|
||||
|
||||
[client]
|
||||
toolbarMode = "minimal"
|
||||
|
||||
[browser]
|
||||
gatherUsageStats = false
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Thin HTTP client for the dashboard. The frozen API schemas (`app/api/schemas.py`) are the only contract.
|
||||
|
||||
The dashboard never touches the databases: everything goes over HTTP to `API_BASE_URL`. Short timeouts keep the
|
||||
UI responsive when the backend is down; every failure is turned into `ApiError` with a Russian message that the
|
||||
page shows as a banner instead of a traceback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from app.api.schemas import (
|
||||
AckIn,
|
||||
AlertOut,
|
||||
ClockOut,
|
||||
ConnectPatientIn,
|
||||
ConnectPatientOut,
|
||||
PatientCard,
|
||||
PatientListItem,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:8000"
|
||||
DEFAULT_DOCTOR_ID = "doc-smirnova"
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Any API failure, with a message safe to show to the doctor (no PII, no stack traces)."""
|
||||
|
||||
def __init__(self, message: str, status: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class ApiUnavailable(ApiError):
|
||||
"""Backend not reachable (connection refused, DNS, timeout)."""
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
doctor_id: str | None = None,
|
||||
*,
|
||||
timeout: float = 3.0,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
base = (base_url or os.environ.get("API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||||
if not base.endswith(API_PREFIX):
|
||||
base += API_PREFIX
|
||||
self.base_url = base
|
||||
self.doctor_id = doctor_id or os.environ.get("DOCTOR_ID") or DEFAULT_DOCTOR_ID
|
||||
self._http = httpx.Client(
|
||||
base_url=base, timeout=httpx.Timeout(timeout, connect=min(timeout, 1.5)), transport=transport
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ doctor endpoints
|
||||
def patients(self) -> list[PatientListItem]:
|
||||
return self._get(f"/doctors/{self.doctor_id}/patients", list[PatientListItem])
|
||||
|
||||
def patient_card(self, token: str) -> PatientCard:
|
||||
return self._get(f"/patients/{token}", PatientCard, params={"doctor_id": self.doctor_id})
|
||||
|
||||
def alerts(self, since_id: int = -1, *, open_only: bool = False) -> list[AlertOut]:
|
||||
params = {"since_id": since_id, "open_only": str(open_only).lower()}
|
||||
return self._get(f"/doctors/{self.doctor_id}/alerts", list[AlertOut], params=params)
|
||||
|
||||
def ack(self, alert_id: int) -> AlertOut:
|
||||
return self._request("POST", f"/alerts/{alert_id}/ack", AlertOut, json=AckIn(doctor_id=self.doctor_id))
|
||||
|
||||
def connect(self, body: ConnectPatientIn) -> ConnectPatientOut:
|
||||
return self._request("POST", "/patients/connect", ConnectPatientOut, json=body)
|
||||
|
||||
def clock(self) -> ClockOut | None:
|
||||
"""Demo clock (ADR-005); optional decoration, so failures return None instead of raising."""
|
||||
try:
|
||||
return self._get("/demo/clock", ClockOut)
|
||||
except ApiError:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ plumbing
|
||||
def _get(self, path: str, model: type[T], params: dict[str, Any] | None = None) -> T:
|
||||
return self._request("GET", path, model, params=params)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
model: type[T],
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: BaseModel | None = None,
|
||||
) -> T:
|
||||
payload = json.model_dump(mode="json") if json is not None else None
|
||||
try:
|
||||
resp = self._http.request(method, path, params=params, json=payload)
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
||||
log.warning("api unavailable: %s %s (%s)", method, _route(path), type(exc).__name__)
|
||||
raise ApiUnavailable("API недоступен — проверьте, что сервис запущен") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning("api transport error: %s %s (%s)", method, _route(path), type(exc).__name__)
|
||||
raise ApiUnavailable("API недоступен — ошибка соединения") from exc
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise ApiError("Не найдено", 404)
|
||||
if resp.status_code >= 400:
|
||||
log.warning("api error: %s %s -> %s", method, _route(path), resp.status_code)
|
||||
raise ApiError(f"Ошибка API ({resp.status_code})", resp.status_code)
|
||||
try:
|
||||
return TypeAdapter(model).validate_python(resp.json())
|
||||
except (ValueError, ValidationError) as exc:
|
||||
log.warning("api bad payload: %s %s", method, _route(path))
|
||||
raise ApiError("API вернул неожиданный ответ") from exc
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
||||
def _route(path: str) -> str:
|
||||
"""Path without identifiers, so logs carry no patient tokens."""
|
||||
parts = path.strip("/").split("/")
|
||||
return "/" + "/".join(p if not any(ch.isdigit() for ch in p) and not p.startswith("tok") else "*" for p in parts)
|
||||
+115
-11
@@ -1,18 +1,122 @@
|
||||
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API."""
|
||||
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
|
||||
|
||||
import os
|
||||
Entry point: `streamlit run dashboard/app.py`. Pages are discovered from `dashboard/pages/*.py` — adding a page
|
||||
is adding a file, app.py never needs editing:
|
||||
|
||||
import httpx
|
||||
* file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
|
||||
spaces (`2_Демо-консоль.py` → «Демо-консоль»);
|
||||
* section: pages with N == 1 go to «Кабинет врача», the rest to «Демо»;
|
||||
* optional module-level constants (read as text, the file is not imported):
|
||||
`NAV_TITLE = "..."`, `NAV_ICON = ":material/terminal:"`, `NAV_SECTION = "..."`, `NAV_URL = "slug"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_HERE = str(Path(__file__).resolve().parent)
|
||||
# `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
|
||||
# (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
|
||||
sys.path[:] = [p for p in sys.path if p not in ("", _HERE) and Path(p or ".").resolve() != Path(_HERE)]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
PAGES_DIR = Path(__file__).resolve().parent / "pages"
|
||||
DOCTOR_SECTION = "Кабинет врача"
|
||||
DEMO_SECTION = "Демо"
|
||||
DEFAULT_ICONS = {
|
||||
"Пациенты": ":material/groups:",
|
||||
"Подключить пациента": ":material/person_add:",
|
||||
"Демо-консоль": ":material/smartphone:",
|
||||
"Сплит-экран": ":material/splitscreen:",
|
||||
}
|
||||
|
||||
_NAME_RE = re.compile(r"^(\d+)([a-z]?)_(.+)$")
|
||||
_META_RE = re.compile(r"""^NAV_(TITLE|ICON|SECTION|URL)\s*(?::\s*str\s*)?=\s*["'](.+?)["']\s*$""", re.MULTILINE)
|
||||
_TRANSLIT = dict(
|
||||
zip(
|
||||
"абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
|
||||
[*"abvgdee", "zh", *"zijklmnoprstufh", "ts", "ch", "sh", "sch", "_", "y", "_", "e", "yu", "ya"],
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageSpec:
|
||||
path: Path
|
||||
title: str
|
||||
icon: str
|
||||
section: str
|
||||
url_path: str
|
||||
order: tuple[int, str]
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
out = "".join(_TRANSLIT.get(ch, ch) for ch in title.lower())
|
||||
out = out.replace("_", "")
|
||||
return re.sub(r"[^a-z0-9]+", "-", out).strip("-") or "page"
|
||||
|
||||
|
||||
def discover_pages(pages_dir: Path = PAGES_DIR) -> list[PageSpec]:
|
||||
specs = []
|
||||
for path in pages_dir.glob("*.py"):
|
||||
m = _NAME_RE.match(path.stem)
|
||||
if not m:
|
||||
continue
|
||||
number, letter, raw = int(m.group(1)), m.group(2), m.group(3)
|
||||
meta = dict(_META_RE.findall(path.read_text(encoding="utf-8")))
|
||||
title = meta.get("TITLE", raw.replace("_", " "))
|
||||
specs.append(
|
||||
PageSpec(
|
||||
path=path,
|
||||
title=title,
|
||||
icon=meta.get("ICON", DEFAULT_ICONS.get(title, ":material/description:")),
|
||||
section=meta.get("SECTION", DOCTOR_SECTION if number == 1 else DEMO_SECTION),
|
||||
url_path=meta.get("URL", slugify(title)),
|
||||
order=(number, letter),
|
||||
)
|
||||
)
|
||||
return sorted(specs, key=lambda s: s.order)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import streamlit as st
|
||||
|
||||
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
from dashboard import theme
|
||||
from dashboard.components import live_alerts, state, ui
|
||||
|
||||
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
|
||||
st.title("Кабинет врача")
|
||||
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
|
||||
st.html(theme.CSS)
|
||||
|
||||
specs = discover_pages()
|
||||
nav: dict[str, list[st.Page]] = {}
|
||||
section_of: dict[str, str] = {}
|
||||
for i, s in enumerate(specs):
|
||||
page = st.Page(str(s.path), title=s.title, icon=s.icon, url_path=s.url_path, default=i == 0)
|
||||
nav.setdefault(s.section, []).append(page)
|
||||
section_of[s.url_path] = s.section
|
||||
current = st.navigation(nav, position="sidebar", expanded=True)
|
||||
|
||||
with st.sidebar:
|
||||
short, _ = state.doctor_names()
|
||||
st.html(
|
||||
'<div style="padding:4px 2px 10px"><div class="mt-kicker">MedITron · сопровождение</div>'
|
||||
f'<div style="font-weight:650;font-size:1rem;margin-top:2px">{short}</div>'
|
||||
'<div class="mt-meta">психиатр · кабинет МПК</div></div>'
|
||||
)
|
||||
st.caption("Прототип на синтетических данных")
|
||||
|
||||
try:
|
||||
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
|
||||
except httpx.HTTPError:
|
||||
status = "недоступен"
|
||||
st.write(f"API: **{status}**")
|
||||
live_alerts.flush_pending_toasts()
|
||||
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario.
|
||||
live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
|
||||
current.run()
|
||||
ui.footer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Plotly figures of the patient card. Pure builders: data in, `go.Figure` out (tested without Streamlit).
|
||||
|
||||
Chart rules: one y-axis per chart (scales of different range are separate charts, daily metrics are small
|
||||
multiples on a shared time axis), a single neutral series colour, status colours only for thresholds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, timedelta
|
||||
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
|
||||
from app.api.schemas import SeriesPoint
|
||||
from dashboard import theme
|
||||
from dashboard.components.helpers import SCALE_MAX, metric_points, severity_bands, short_date
|
||||
|
||||
# Band shading: one warm hue with growing opacity (sequential), so "higher = worse" reads without a legend.
|
||||
_BAND_ALPHA = [0.0, 0.05, 0.09, 0.14, 0.20]
|
||||
|
||||
|
||||
def _day_ticks(days: list[date], max_ticks: int = 10) -> dict[str, object]:
|
||||
if not days:
|
||||
return {}
|
||||
first, last = min(days), max(days)
|
||||
span = (last - first).days + 1
|
||||
step = max(1, -(-span // max_ticks))
|
||||
ticks = [first + timedelta(days=i) for i in range(0, span, step)]
|
||||
return {"tickmode": "array", "tickvals": ticks, "ticktext": [short_date(d) for d in ticks]}
|
||||
|
||||
|
||||
def _point_label(p: SeriesPoint) -> str:
|
||||
return f"{p.value:g}" if p.scheduled else f"{p.value:g} · внепл."
|
||||
|
||||
|
||||
def _therapy_day(d: date, start: date | None) -> str:
|
||||
return f"день {(d - start).days}" if start else short_date(d)
|
||||
|
||||
|
||||
def scale_figure(
|
||||
series: Iterable[SeriesPoint], scale_id: str, title: str, start: date | None = None
|
||||
) -> go.Figure | None:
|
||||
"""Total score over time with the severity gradations from config/scales; unscheduled sessions are hollow."""
|
||||
pts = metric_points(series, scale_id)
|
||||
if not pts:
|
||||
return None
|
||||
top = SCALE_MAX.get(scale_id, max(p.value for p in pts))
|
||||
fig = go.Figure()
|
||||
bands = severity_bands(scale_id)
|
||||
for i, b in enumerate(bands):
|
||||
alpha = _BAND_ALPHA[min(i, len(_BAND_ALPHA) - 1)] if len(bands) > 3 else [0.0, 0.08, 0.18][min(i, 2)]
|
||||
fig.add_hrect(y0=b.lo, y1=b.hi + 1 if b.hi < top else top, line_width=0, fillcolor=f"rgba(214,69,69,{alpha})")
|
||||
fig.add_annotation(
|
||||
x=0,
|
||||
xref="paper",
|
||||
xanchor="left",
|
||||
y=(b.lo + min(b.hi + 1, top)) / 2,
|
||||
text=b.label,
|
||||
showarrow=False,
|
||||
font={"size": 10, "color": "rgba(122,130,140,0.85)"},
|
||||
)
|
||||
xs = [p.day for p in pts]
|
||||
hover = [f"{short_date(p.day)} · {_therapy_day(p.day, start)}" for p in pts]
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=xs,
|
||||
y=[p.value for p in pts],
|
||||
mode="lines+markers+text",
|
||||
line={"color": theme.SERIES, "width": 2},
|
||||
marker={
|
||||
"size": [9 if p.scheduled else 13 for p in pts],
|
||||
"symbol": ["circle" if p.scheduled else "diamond-open" for p in pts],
|
||||
"color": theme.SERIES,
|
||||
"line": {"width": [2 if p.scheduled else 2.5 for p in pts], "color": "#FFFFFF"},
|
||||
},
|
||||
text=[_point_label(p) if i == len(pts) - 1 else "" for i, p in enumerate(pts)],
|
||||
textposition="top left",
|
||||
textfont={"color": theme.INK, "size": 12},
|
||||
customdata=[[h, "плановый" if p.scheduled else "внеплановый"] for h, p in zip(hover, pts, strict=True)],
|
||||
hovertemplate="%{customdata[0]}<br><b>%{y:g}</b> баллов · %{customdata[1]}<extra></extra>",
|
||||
name=title,
|
||||
)
|
||||
)
|
||||
pad = timedelta(days=1)
|
||||
fig.update_layout(
|
||||
theme.plotly_layout(title={"text": title, "font": {"size": 14, "color": theme.INK}, "x": 0}, height=260),
|
||||
hovermode="closest",
|
||||
)
|
||||
fig.update_xaxes(**theme.AXIS, **_day_ticks(xs, 4), tickangle=0, range=[min(xs) - pad, max(xs) + pad])
|
||||
fig.update_yaxes(**theme.AXIS, range=[0, top], fixedrange=True)
|
||||
return fig
|
||||
|
||||
|
||||
def wellbeing_figure(series: Iterable[SeriesPoint], start: date | None = None) -> go.Figure | None:
|
||||
"""Daily check-in as three small multiples on one time axis: mood 0–10, sleep hours, alcohol 0/1/2."""
|
||||
data = list(series)
|
||||
mood, sleep, alcohol = (metric_points(data, m) for m in ("mood", "sleep_hours", "alcohol"))
|
||||
if not (mood or sleep or alcohol):
|
||||
return None
|
||||
days = sorted({p.day for p in mood + sleep + alcohol})
|
||||
fig = make_subplots(
|
||||
rows=3,
|
||||
cols=1,
|
||||
shared_xaxes=True,
|
||||
vertical_spacing=0.09,
|
||||
row_heights=[0.42, 0.33, 0.25],
|
||||
subplot_titles=("Настроение, 0–10", "Сон, часов", "Алкоголь"),
|
||||
)
|
||||
red_line = {"color": theme.TONES["red"].solid, "width": 1, "dash": "dot"}
|
||||
|
||||
def hov(p: SeriesPoint) -> str:
|
||||
return f"{short_date(p.day)} · {_therapy_day(p.day, start)}"
|
||||
|
||||
if mood:
|
||||
# Mood ≤ 3 for three days triggers an unscheduled PHQ-9 (config/rules.yaml) — shade that zone.
|
||||
fig.add_hrect(y0=0, y1=3, line_width=0, fillcolor="rgba(214,69,69,0.08)", row=1, col=1)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[p.day for p in mood],
|
||||
y=[p.value for p in mood],
|
||||
mode="lines+markers",
|
||||
line={"color": theme.SERIES, "width": 2},
|
||||
marker={"size": 8, "color": theme.SERIES, "line": {"width": 2, "color": "#FFFFFF"}},
|
||||
customdata=[hov(p) for p in mood],
|
||||
hovertemplate="%{customdata}<br>настроение <b>%{y:g}</b>/10<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
if sleep:
|
||||
fig.add_trace(
|
||||
go.Bar(
|
||||
x=[p.day for p in sleep],
|
||||
y=[p.value for p in sleep],
|
||||
marker={"color": [theme.TONES["red"].solid if p.value < 4 else "#9DBFEA" for p in sleep]},
|
||||
marker_cornerradius=4,
|
||||
customdata=[hov(p) for p in sleep],
|
||||
hovertemplate="%{customdata}<br>сон ≈ <b>%{y:g}</b> ч<extra></extra>",
|
||||
),
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
fig.add_hline(y=4, line=red_line, row=2, col=1)
|
||||
if alcohol:
|
||||
alc_label = {0: "нет", 1: "1–2 порции", 2: "больше"}
|
||||
fig.add_trace(
|
||||
go.Bar(
|
||||
x=[p.day for p in alcohol],
|
||||
y=[max(p.value, 0.08) for p in alcohol],
|
||||
marker={
|
||||
"color": [
|
||||
{0: theme.LINE, 1: theme.TONES["yellow"].solid}.get(int(p.value), theme.TONES["red"].solid)
|
||||
for p in alcohol
|
||||
]
|
||||
},
|
||||
marker_cornerradius=4,
|
||||
customdata=[[hov(p), alc_label.get(int(p.value), "?")] for p in alcohol],
|
||||
hovertemplate="%{customdata[0]}<br>алкоголь: <b>%{customdata[1]}</b><extra></extra>",
|
||||
),
|
||||
row=3,
|
||||
col=1,
|
||||
)
|
||||
fig.update_layout(theme.plotly_layout(height=430, bargap=0.25, hovermode="x"))
|
||||
fig.update_annotations(font={"size": 12, "color": theme.INK_2}, x=0, xanchor="left")
|
||||
pad = timedelta(hours=14)
|
||||
fig.update_xaxes(**theme.AXIS, range=[days[0] - pad, days[-1] + pad])
|
||||
fig.update_xaxes(**_day_ticks(days), row=3, col=1)
|
||||
fig.update_yaxes(**theme.AXIS, fixedrange=True)
|
||||
fig.update_yaxes(range=[0, 10.5], tickvals=[0, 3, 5, 10], row=1, col=1)
|
||||
fig.update_yaxes(range=[0, 9], tickvals=[0, 4, 8], row=2, col=1)
|
||||
fig.update_yaxes(range=[0, 2.2], tickvals=[0, 1, 2], ticktext=["0", "1–2", "3+"], row=3, col=1)
|
||||
return fig
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Pure presentation logic of the dashboard (no Streamlit calls), unit-tested in tests/test_dashboard_helpers.py.
|
||||
|
||||
Clinical meaning stays on the backend: colours come from ADR-006 levels computed by the rules engine and the
|
||||
discontinuation-risk index is computed server-side. Here we only order, label and reshape that data for display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
|
||||
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, PatientListItem, SeriesPoint
|
||||
|
||||
TZ = ZoneInfo("Europe/Moscow")
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# ADR-006 ordering for the doctor's attention: acute first, then high/moderate, then info-only, then quiet.
|
||||
STATUS_ORDER = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
|
||||
|
||||
MODULE_LABELS = {"veteran": "ветеран", "depression": "депрессия", "family": "семья"}
|
||||
LEVEL_LABELS = {"acute": "Кризис", "high": "Высокий", "moderate": "Умеренный", "info": "Инфо"}
|
||||
MONTHS_SHORT = ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]
|
||||
|
||||
DRUGS: dict[str, list[str]] = {
|
||||
"сертралин": ["25 мг", "50 мг", "100 мг", "150 мг"],
|
||||
"пароксетин": ["10 мг", "20 мг", "30 мг"],
|
||||
"эсциталопрам": ["5 мг", "10 мг", "20 мг"],
|
||||
"флуоксетин": ["20 мг", "40 мг"],
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ list: ordering, filtering, KPIs
|
||||
def sort_patients(items: Iterable[PatientListItem]) -> list[PatientListItem]:
|
||||
"""red → yellow → grey → green; inside a colour the higher discontinuation risk goes first."""
|
||||
return sorted(items, key=lambda p: (STATUS_ORDER[p.status_color], -p.discontinuation_risk.score, p.display_name))
|
||||
|
||||
|
||||
def filter_patients(
|
||||
items: Iterable[PatientListItem], color: str | None = None, query: str = ""
|
||||
) -> list[PatientListItem]:
|
||||
q = query.strip().lower()
|
||||
return [
|
||||
p
|
||||
for p in items
|
||||
if (color is None or p.status_color == color)
|
||||
and (not q or q in p.display_name.lower() or q in (p.drug or "").lower())
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Kpis:
|
||||
total: int
|
||||
red: int
|
||||
yellow: int
|
||||
grey: int
|
||||
green: int
|
||||
high_risk: int
|
||||
|
||||
|
||||
def kpis(items: Sequence[PatientListItem]) -> Kpis:
|
||||
by = {c: sum(1 for p in items if p.status_color == c) for c in STATUS_ORDER}
|
||||
high = sum(1 for p in items if p.discontinuation_risk.level == "high")
|
||||
return Kpis(len(items), by["red"], by["yellow"], by["grey"], by["green"], high)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ small visual mappings
|
||||
@dataclass(frozen=True)
|
||||
class View:
|
||||
text: str
|
||||
tone: str # key of theme.TONES
|
||||
hint: str = ""
|
||||
|
||||
|
||||
def trend_view(trend: str) -> View:
|
||||
"""Direction of symptom scores; "up" means symptoms got worse (schemas.PatientListItem.trend)."""
|
||||
return {
|
||||
"up": View("↑", "red", "симптомы нарастают"),
|
||||
"down": View("↓", "green", "симптомы снижаются"),
|
||||
"flat": View("→", "grey", "без динамики"),
|
||||
}.get(trend, View("·", "grey", "мало данных"))
|
||||
|
||||
|
||||
def risk_view(risk: DiscontinuationRisk) -> View:
|
||||
"""Badge for the rule-based discontinuation-risk index (score 0–100) with factors as a tooltip."""
|
||||
label, tone = {"high": ("Высокий", "red"), "medium": ("Средний", "yellow"), "low": ("Низкий", "green")}[risk.level]
|
||||
factors = "\n".join(f"• {f}" for f in risk.factors) or "• значимых факторов нет"
|
||||
return View(f"{label} · {risk.score}", tone, f"Риск прерывания терапии: {risk.score}/100\n{factors}")
|
||||
|
||||
|
||||
def adherence_view(adherence: float | None) -> View:
|
||||
"""7-day adherence. ≥ 80 % is the usual "adherent" cut-off for antidepressants; < 60 % needs a call."""
|
||||
if adherence is None:
|
||||
return View("—", "grey")
|
||||
pct = round(adherence * 100)
|
||||
tone = "green" if adherence >= 0.8 else "yellow" if adherence >= 0.6 else "red"
|
||||
return View(f"{pct}%", tone)
|
||||
|
||||
|
||||
def status_view(color: str, top_level: str | None = None) -> View:
|
||||
if color == "red":
|
||||
return View("Кризис", "red", "острый сигнал — связаться сегодня")
|
||||
if color == "yellow":
|
||||
return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту")
|
||||
if color == "grey":
|
||||
return View("Нет ответа", "grey", "молчание / только инфо-флаги")
|
||||
return View("Стабильно", "green", "открытых сигналов нет")
|
||||
|
||||
|
||||
def level_tone(level: str) -> str:
|
||||
return {"acute": "red", "high": "yellow", "moderate": "yellow"}.get(level, "grey")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ scales
|
||||
@dataclass(frozen=True)
|
||||
class Band:
|
||||
lo: float
|
||||
hi: float
|
||||
code: str
|
||||
label: str
|
||||
|
||||
|
||||
SCALE_MAX = {"phq9": 27, "pcl5": 80}
|
||||
|
||||
|
||||
@cache
|
||||
def severity_bands(scale_id: str) -> tuple[Band, ...]:
|
||||
"""Severity gradations from config/scales/<id>.yaml (ADR-008: thresholds live in config, not code)."""
|
||||
path = REPO_ROOT / "config" / "scales" / f"{scale_id}.yaml"
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except OSError:
|
||||
return ()
|
||||
rows = data.get("severity", [])
|
||||
return tuple(Band(float(b["min"]), float(b["max"]), str(b["code"]), str(b["label"])) for b in rows)
|
||||
|
||||
|
||||
def severity_label(scale_id: str, value: float | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
for b in severity_bands(scale_id):
|
||||
if b.lo <= value <= b.hi:
|
||||
return b.label
|
||||
return ""
|
||||
|
||||
|
||||
def metric_points(series: Iterable[SeriesPoint], metric: str) -> list[SeriesPoint]:
|
||||
return sorted((p for p in series if p.metric == metric), key=lambda p: p.day)
|
||||
|
||||
|
||||
def last_and_prev(series: Iterable[SeriesPoint], metric: str) -> tuple[float | None, float | None]:
|
||||
pts = metric_points(series, metric)
|
||||
last = pts[-1].value if pts else None
|
||||
prev = pts[-2].value if len(pts) > 1 else None
|
||||
return last, prev
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ dose calendar
|
||||
@dataclass
|
||||
class DoseGrid:
|
||||
"""Heat-strip of doses: one column per calendar day, one row per scheduled time of day."""
|
||||
|
||||
days: list[date] = field(default_factory=list)
|
||||
slots: list[str] = field(default_factory=list)
|
||||
cells: dict[tuple[str, date], str] = field(default_factory=dict)
|
||||
counts: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def adherence(self) -> float | None:
|
||||
"""Share of resolved doses taken (pending doses are not counted yet)."""
|
||||
resolved = sum(self.counts.get(s, 0) for s in ("taken", "missed", "skipped"))
|
||||
return self.counts.get("taken", 0) / resolved if resolved else None
|
||||
|
||||
|
||||
def _local(dt: datetime) -> datetime:
|
||||
return dt.astimezone(TZ) if dt.tzinfo else dt
|
||||
|
||||
|
||||
def dose_grid(doses: Iterable[DoseOut]) -> DoseGrid:
|
||||
grid = DoseGrid(counts={"taken": 0, "missed": 0, "skipped": 0, "pending": 0})
|
||||
items = sorted(doses, key=lambda d: d.planned_at)
|
||||
if not items:
|
||||
return grid
|
||||
days: set[date] = set()
|
||||
slots: set[str] = set()
|
||||
for d in items:
|
||||
local = _local(d.planned_at)
|
||||
day, slot = local.date(), local.strftime("%H:%M")
|
||||
days.add(day)
|
||||
slots.add(slot)
|
||||
grid.cells[(slot, day)] = d.status
|
||||
grid.counts[d.status] += 1
|
||||
first, last = min(days), max(days)
|
||||
grid.days = [date.fromordinal(o) for o in range(first.toordinal(), last.toordinal() + 1)]
|
||||
grid.slots = sorted(slots)
|
||||
return grid
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ alerts
|
||||
def apply_local_acks(alerts: Iterable[AlertOut], acks: dict[int, tuple[str, datetime]]) -> list[AlertOut]:
|
||||
"""Overlay acks done in this session (so the UI reflects the click even before the next poll)."""
|
||||
out = []
|
||||
for a in alerts:
|
||||
if a.acked_at is None and a.id in acks:
|
||||
by, at = acks[a.id]
|
||||
a = a.model_copy(update={"acked_by": by, "acked_at": at})
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
|
||||
def split_new(alerts: Iterable[AlertOut], seen: set[int]) -> list[AlertOut]:
|
||||
return sorted((a for a in alerts if a.id not in seen), key=lambda a: a.id)
|
||||
|
||||
|
||||
def sort_alerts(alerts: Iterable[AlertOut]) -> list[AlertOut]:
|
||||
"""Open first (acute before others), then newest first."""
|
||||
rank = {"acute": 0, "high": 1, "moderate": 2, "info": 3}
|
||||
return sorted(alerts, key=lambda a: (a.acked_at is not None, rank.get(a.level, 9), -a.ts.timestamp()))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Russian formatting
|
||||
def plural_ru(n: int, one: str, few: str, many: str) -> str:
|
||||
n = abs(n)
|
||||
if n % 10 == 1 and n % 100 != 11:
|
||||
return one
|
||||
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
|
||||
return few
|
||||
return many
|
||||
|
||||
|
||||
def age_label(age: int) -> str:
|
||||
if age <= 0:
|
||||
return "возраст не указан"
|
||||
return f"{age} {plural_ru(age, 'год', 'года', 'лет')}"
|
||||
|
||||
|
||||
def short_date(d: date) -> str:
|
||||
return f"{d.day} {MONTHS_SHORT[d.month - 1]}"
|
||||
|
||||
|
||||
def when_label(dt: datetime, today: date | None = None) -> str:
|
||||
local = _local(dt)
|
||||
day = "сегодня" if today and local.date() == today else short_date(local.date())
|
||||
return f"{day}, {local:%H:%M}"
|
||||
|
||||
|
||||
def visit_label(visit: date | None, today: date | None) -> str:
|
||||
if visit is None:
|
||||
return "визит не назначен"
|
||||
if today is None:
|
||||
return f"визит {short_date(visit)}"
|
||||
delta = (visit - today).days
|
||||
if delta == 0:
|
||||
return "визит сегодня"
|
||||
if delta < 0:
|
||||
return f"визит {short_date(visit)} (просрочен)"
|
||||
return f"визит {short_date(visit)} · через {delta} {plural_ru(delta, 'день', 'дня', 'дней')}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ connect form
|
||||
def absolute_link(link: str, public_url: str) -> str:
|
||||
"""The API may return a relative web-console link (`/console?code=…`); a QR needs an absolute URL."""
|
||||
if link.startswith(("http://", "https://", "tg://")):
|
||||
return link
|
||||
return public_url.rstrip("/") + "/" + link.lstrip("/")
|
||||
|
||||
|
||||
def snils_checksum(digits: str) -> int:
|
||||
"""Control number of a SNILS (Pension Fund algorithm): Σ digit·(9−i), then mod 101, 100/101 → 0."""
|
||||
s = sum(int(ch) * (9 - i) for i, ch in enumerate(digits))
|
||||
if s < 100:
|
||||
return s
|
||||
if s in (100, 101):
|
||||
return 0
|
||||
s %= 101
|
||||
return 0 if s == 100 else s
|
||||
|
||||
|
||||
def format_snils(digits: str, check: int) -> str:
|
||||
return f"{digits[0:3]}-{digits[3:6]}-{digits[6:9]} {check:02d}"
|
||||
|
||||
|
||||
def is_valid_snils(value: str) -> bool:
|
||||
d = re.sub(r"\D", "", value)
|
||||
return len(d) == 11 and snils_checksum(d[:9]) == int(d[9:])
|
||||
|
||||
|
||||
def synthetic_snils(rng: random.Random | None = None) -> str:
|
||||
"""Random checksum-valid SNILS for synthetic test patients. Starts with 000 so it never looks like a real one."""
|
||||
r = rng or random.Random()
|
||||
digits = "000" + "".join(str(r.randint(0, 9)) for _ in range(6))
|
||||
return format_snils(digits, snils_checksum(digits))
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Live alerts across all pages: polls the API every few seconds, toasts new alerts, pins open acute ones on top.
|
||||
|
||||
Uses the built-in `st.fragment(run_every=...)`: only this fragment re-runs on the timer, the page is untouched
|
||||
unless a new alert arrives on a doctor page (then the whole app reruns so the list and the card pick it up).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.api.schemas import AlertOut
|
||||
from dashboard import theme
|
||||
from dashboard.api_client import ApiError
|
||||
from dashboard.components import helpers as h
|
||||
from dashboard.components import state, ui
|
||||
|
||||
POLL_SECONDS = 2.5
|
||||
MAX_BANNER = 3
|
||||
|
||||
|
||||
def _toast(a: AlertOut) -> None:
|
||||
icon = theme.TONES[h.level_tone(a.level)].icon
|
||||
st.toast(f"**{a.patient_name}** — {a.reason}", icon=icon, duration="long" if a.level == "acute" else "short")
|
||||
|
||||
|
||||
def flush_pending_toasts() -> None:
|
||||
"""Toasts queued right before a full-app rerun are shown on the next run (a rerun drops pending deltas)."""
|
||||
for a in st.session_state.pop("pending_toasts", []):
|
||||
_toast(a)
|
||||
|
||||
|
||||
def _banner(acute: list[AlertOut]) -> None:
|
||||
for a in acute[:MAX_BANNER]:
|
||||
left, right = st.columns([8, 1.6], vertical_alignment="center")
|
||||
with left:
|
||||
st.html(
|
||||
ui.banner(
|
||||
f"<b>Кризисный сигнал · {escape(a.patient_name)}</b> — {escape(a.reason)}"
|
||||
f'<div style="font-size:.8rem;margin-top:2px">{escape(h.when_label(a.ts))} · '
|
||||
"по протоколу — связаться с пациентом сегодня</div>",
|
||||
tone="red",
|
||||
pulse=True,
|
||||
)
|
||||
)
|
||||
with right:
|
||||
if st.button(
|
||||
"Карточка", key=f"acute-open-{a.id}", type="primary", icon=":material/arrow_forward:", width="stretch"
|
||||
):
|
||||
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": a.token})
|
||||
if len(acute) > MAX_BANNER:
|
||||
st.caption(f"Ещё кризисных сигналов: {len(acute) - MAX_BANNER}")
|
||||
|
||||
|
||||
def render(*, rerun_app_on_new: bool = True) -> None:
|
||||
"""Place once per run (app.py does it above every page)."""
|
||||
|
||||
@st.fragment(run_every=POLL_SECONDS)
|
||||
def _poll() -> None:
|
||||
ss = st.session_state
|
||||
try:
|
||||
alerts = state.client().alerts(open_only=True)
|
||||
except ApiError:
|
||||
return # pages show their own "API недоступен" banner; the poller just retries next tick
|
||||
acked = state.local_acks()
|
||||
alerts = [a for a in alerts if a.acked_at is None and a.id not in acked]
|
||||
|
||||
seen: set[int] | None = ss.get("seen_alert_ids")
|
||||
if seen is None:
|
||||
# First poll of the session: what is already open is not "new" — no toast storm on page load.
|
||||
ss["seen_alert_ids"] = {a.id for a in alerts}
|
||||
new: list[AlertOut] = []
|
||||
else:
|
||||
new = h.split_new(alerts, seen)
|
||||
seen.update(a.id for a in new)
|
||||
|
||||
if new and rerun_app_on_new:
|
||||
ss["pending_toasts"] = ss.get("pending_toasts", []) + new
|
||||
st.rerun(scope="app")
|
||||
for a in new:
|
||||
_toast(a)
|
||||
_banner(h.sort_alerts(a for a in alerts if a.level == "acute"))
|
||||
|
||||
_poll()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Patient card: header, key numbers, charts, dose calendar, alerts feed with ack, safety plan, AI summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from html import escape
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.api.schemas import AlertOut, PatientCard
|
||||
from dashboard import theme
|
||||
from dashboard.api_client import ApiError
|
||||
from dashboard.components import charts, state, ui
|
||||
from dashboard.components import helpers as h
|
||||
|
||||
|
||||
def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
|
||||
"""Heat-strip: columns = days, rows = scheduled times. Glyphs duplicate colour for colour-blind readers."""
|
||||
if not grid.days:
|
||||
return '<div class="mt-dim">Данных о приёме пока нет</div>'
|
||||
glyph = {"taken": "✓", "missed": "✕", "skipped": "–", "pending": "·"}
|
||||
label = {"taken": "принял", "missed": "нет ответа", "skipped": "пропустил", "pending": "ожидается"}
|
||||
cols = f"56px repeat({len(grid.days)}, minmax(18px, 1fr))"
|
||||
cells = ['<div class="lab"></div>']
|
||||
cells += [f'<div class="lab">{d.day}</div>' for d in grid.days]
|
||||
for slot in grid.slots:
|
||||
cells.append(f'<div class="lab" style="text-align:left">{slot}</div>')
|
||||
for d in grid.days:
|
||||
status = grid.cells.get((slot, d))
|
||||
if status is None:
|
||||
cells.append('<div class="cell c-none"></div>')
|
||||
continue
|
||||
day = f" · день {(d - start).days}" if start else ""
|
||||
tip = f"{h.short_date(d)}{day}, {slot}: {label[status]}"
|
||||
cells.append(f'<div class="cell c-{status}" title="{escape(tip)}">{glyph[status]}</div>')
|
||||
c = grid.counts
|
||||
legend = (
|
||||
'<div class="mt-legend">'
|
||||
f'<span><i style="background:var(--green)"></i>принял · {c["taken"]}</span>'
|
||||
f'<span><i style="background:var(--yellow)"></i>пропустил · {c["skipped"]}</span>'
|
||||
f'<span><i style="background:var(--red)"></i>нет ответа · {c["missed"]}</span>'
|
||||
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
|
||||
"</div>"
|
||||
)
|
||||
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}'
|
||||
|
||||
|
||||
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
|
||||
return (
|
||||
f'<div class="mt-kpi" style="--kpi-accent:{theme.TONES[tone].solid}"><div class="lbl">{escape(label)}</div>'
|
||||
f'<div class="val">{value}</div><div class="hint">{escape(hint)}</div></div>'
|
||||
)
|
||||
|
||||
|
||||
def _score_tile(card: PatientCard, scale_id: str, title: str) -> str:
|
||||
last, prev = h.last_and_prev(card.series, scale_id)
|
||||
if last is None:
|
||||
return _tile(title, '<span class="mt-dim">—</span>', "не проводился", "grey")
|
||||
delta = ""
|
||||
if prev is not None and last != prev:
|
||||
arrow = "↑" if last > prev else "↓"
|
||||
tone = "red" if last > prev else "green"
|
||||
delta = f' <span style="font-size:1rem;color:{theme.TONES[tone].ink}">{arrow} с {prev:g}</span>'
|
||||
sev = h.severity_label(scale_id, last)
|
||||
return _tile(title, f"{last:g}{delta}", sev, "red" if last > (prev or last) else "blue")
|
||||
|
||||
|
||||
def _header(card: PatientCard, today: date | None) -> None:
|
||||
p = card.patient
|
||||
status = h.status_view(p.status_color)
|
||||
risk = h.risk_view(p.discontinuation_risk)
|
||||
chips = "".join(ui.chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
|
||||
meta = (
|
||||
f"{h.age_label(p.age)} · {chips} · {escape(p.drug or 'без назначения')} · день терапии {p.therapy_day}"
|
||||
f" · {h.visit_label(p.next_visit, today)}"
|
||||
)
|
||||
st.html(
|
||||
'<div class="mt-head"><div>'
|
||||
f'<div style="margin-bottom:6px">{ui.badge(status.text, status.tone, status.hint, icon="dot")}</div>'
|
||||
f'<h1>{escape(p.display_name)}</h1><div class="mt-sub">{meta}</div></div>'
|
||||
f'<div class="mt-side"><div class="mt-kicker" style="margin-bottom:4px">Риск прерывания терапии</div>'
|
||||
f"{ui.badge(risk.text, risk.tone, risk.hint)}"
|
||||
f'<div class="mt-meta">{escape(", ".join(p.discontinuation_risk.factors))}</div>'
|
||||
"</div></div>"
|
||||
)
|
||||
|
||||
|
||||
def _ack(alert: AlertOut) -> None:
|
||||
try:
|
||||
res = state.client().ack(alert.id)
|
||||
except ApiError as exc:
|
||||
st.toast(f"Не удалось подтвердить: {exc.message}", icon="⚠️")
|
||||
return
|
||||
state.local_acks()[alert.id] = (res.acked_by or state.client().doctor_id, res.acked_at or state.now() or alert.ts)
|
||||
st.toast(f"Алерт подтверждён: {alert.reason}", icon="✅")
|
||||
|
||||
|
||||
def _alerts_feed(alerts: list[AlertOut], today: date | None) -> None:
|
||||
st.html('<div class="mt-panel-title">Алерты</div>')
|
||||
if not alerts:
|
||||
st.caption("Сигналов нет")
|
||||
return
|
||||
for a in h.sort_alerts(alerts):
|
||||
tone = h.level_tone(a.level)
|
||||
acked = a.acked_at is not None
|
||||
level = h.LEVEL_LABELS.get(a.level, a.level)
|
||||
status = f"подтверждён {h.when_label(a.acked_at, today)}" if a.acked_at else "открыт"
|
||||
st.html(
|
||||
f'<div class="mt-alert t-{tone}{" acked" if acked else ""}">'
|
||||
f'<div class="when">{theme.TONES[tone].icon} {level} · {escape(h.when_label(a.ts, today))}</div>'
|
||||
f'<div class="why">{escape(a.reason)}</div>'
|
||||
f'<div class="when">{escape(status)}</div></div>'
|
||||
)
|
||||
if not acked:
|
||||
st.button(
|
||||
"Подтвердить",
|
||||
key=f"ack-{a.id}",
|
||||
on_click=_ack,
|
||||
args=(a,),
|
||||
icon=":material/check:",
|
||||
type="primary" if a.level == "acute" else "secondary",
|
||||
help="Врач видел сигнал и принял меры (запись в журнал аудита)",
|
||||
)
|
||||
|
||||
|
||||
def _summary(text: str) -> None:
|
||||
st.html('<div class="mt-panel-title">Саммари (ИИ, по структурированным данным)</div>')
|
||||
body = escape(text) if text else '<span class="mt-dim">Саммари ещё не сформировано</span>'
|
||||
st.html(
|
||||
f'<div class="mt-summary">{body}<div class="disc">Сформировано по структурированным данным '
|
||||
"(шкалы, чек-ины, приём, алерты) без свободного текста пациента. Решения принимает врач.</div></div>"
|
||||
)
|
||||
|
||||
|
||||
def _safety_plan(plan: list[str] | None) -> None:
|
||||
st.html('<div class="mt-panel-title">План безопасности</div>')
|
||||
if not plan:
|
||||
st.caption("Не составлен. Составляется с пациентом при острых сигналах (Stanley–Brown).")
|
||||
return
|
||||
items = "".join(f"<li>{escape(step)}</li>" for step in plan)
|
||||
st.html(f'<div class="mt-plan"><ol>{items}</ol></div>')
|
||||
|
||||
|
||||
def _prescriptions(card: PatientCard) -> None:
|
||||
st.html('<div class="mt-panel-title">Назначения</div>')
|
||||
if not card.prescriptions:
|
||||
st.caption(card.patient.drug or "Нет активных назначений")
|
||||
return
|
||||
for rx in card.prescriptions:
|
||||
times = ", ".join(rx.schedule.get("times", []))
|
||||
since = f" · с {h.short_date(rx.start_date)}" if rx.start_date else ""
|
||||
st.html(
|
||||
f'<div style="margin-bottom:6px"><b>{escape(rx.drug)} {escape(rx.dose)}</b>'
|
||||
f'<div class="mt-meta">{escape(times)} · {escape(rx.instructions or "без особых указаний")}{since}</div>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def render(card: PatientCard, today: date | None) -> None:
|
||||
card = card.model_copy(update={"alerts": h.apply_local_acks(card.alerts, state.local_acks())})
|
||||
start = card.prescriptions[0].start_date if card.prescriptions else None
|
||||
_header(card, today)
|
||||
|
||||
grid = h.dose_grid(card.doses)
|
||||
adh = h.adherence_view(card.patient.adherence_7d)
|
||||
open_alerts = [a for a in card.alerts if a.acked_at is None]
|
||||
taken = f"{grid.counts['taken']} из {sum(grid.counts[s] for s in ('taken', 'missed', 'skipped'))} доз за курс"
|
||||
st.html(
|
||||
'<div class="mt-kpis">'
|
||||
+ _score_tile(card, "phq9", "PHQ-9 · депрессия")
|
||||
+ _score_tile(card, "pcl5", "PCL-5 · ПТСР")
|
||||
+ _tile("Приём за 7 дней", adh.text, taken if grid.days else "", adh.tone)
|
||||
+ _tile(
|
||||
"Открытые алерты",
|
||||
str(len(open_alerts)),
|
||||
"есть кризисный" if any(a.level == "acute" for a in open_alerts) else "",
|
||||
"red" if any(a.level == "acute" for a in open_alerts) else ("yellow" if open_alerts else "green"),
|
||||
)
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
main, side = st.columns([1.75, 1], gap="large")
|
||||
with main:
|
||||
with st.container(border=True):
|
||||
c1, c2 = st.columns(2)
|
||||
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
|
||||
fig = charts.scale_figure(card.series, scale_id, title, start)
|
||||
with col:
|
||||
if fig is None:
|
||||
st.caption("Шкала ещё не проводилась")
|
||||
else:
|
||||
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
|
||||
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
|
||||
with st.container(border=True):
|
||||
fig = charts.wellbeing_figure(card.series, start)
|
||||
if fig is None:
|
||||
st.caption("Ежедневных чек-инов пока нет")
|
||||
else:
|
||||
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
|
||||
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
|
||||
with st.container(border=True):
|
||||
st.html('<div class="mt-panel-title">Календарь приёма</div>')
|
||||
st.html(dose_strip_html(grid, start))
|
||||
with side:
|
||||
with st.container(border=True):
|
||||
_alerts_feed(card.alerts, today)
|
||||
with st.container(border=True):
|
||||
_summary(card.summary)
|
||||
with st.container(border=True):
|
||||
_safety_plan(card.safety_plan)
|
||||
with st.container(border=True):
|
||||
_prescriptions(card)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Session-level plumbing shared by all dashboard pages: API client, demo "today", local acks, error banners."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from html import escape
|
||||
from typing import TypeVar
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
|
||||
from dashboard.components import ui
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Short doctor names for the header; no other doctor PII is shown.
|
||||
DOCTORS = {"doc-smirnova": ("д-р Смирнова Е. А.", "д-ра Смирновой Е. А.")}
|
||||
|
||||
PATIENTS_PAGE = "pages/1_Пациенты.py"
|
||||
CONNECT_PAGE = "pages/1b_Подключить_пациента.py"
|
||||
|
||||
|
||||
@st.cache_resource
|
||||
def _client(base_url: str | None, doctor_id: str | None) -> ApiClient:
|
||||
return ApiClient(base_url, doctor_id)
|
||||
|
||||
|
||||
def client() -> ApiClient:
|
||||
return _client(os.environ.get("API_BASE_URL"), os.environ.get("DOCTOR_ID"))
|
||||
|
||||
|
||||
def doctor_names() -> tuple[str, str]:
|
||||
did = client().doctor_id
|
||||
return DOCTORS.get(did, (did, did))
|
||||
|
||||
|
||||
def now() -> datetime | None:
|
||||
"""Demo clock from the API (ADR-005), so day 13 of the scenario is "today" on screen; None if unknown."""
|
||||
c = client().clock()
|
||||
return c.now if c else None
|
||||
|
||||
|
||||
def today() -> date | None:
|
||||
n = now()
|
||||
return n.date() if n else None
|
||||
|
||||
|
||||
def local_acks() -> dict[int, tuple[str, datetime]]:
|
||||
return st.session_state.setdefault("acked_local", {})
|
||||
|
||||
|
||||
def call(fn: Callable[[], T]) -> T | None:
|
||||
"""Run an API call; on failure show a calm banner instead of a traceback and return None."""
|
||||
try:
|
||||
return fn()
|
||||
except ApiUnavailable as exc:
|
||||
st.html(ui.banner(f"<b>API недоступен.</b> {escape(exc.message)}. Данные обновятся автоматически.", "grey"))
|
||||
except ApiError as exc:
|
||||
st.html(ui.banner(f"<b>Не удалось загрузить данные.</b> {escape(exc.message)}", "grey"))
|
||||
return None
|
||||
@@ -0,0 +1,109 @@
|
||||
"""HTML fragments of the dashboard. Builders return strings (testable); `st.html` renders them.
|
||||
|
||||
Every value coming from the API is escaped: patient free text never reaches these views, but the API is still
|
||||
treated as untrusted input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.api.schemas import PatientListItem
|
||||
from dashboard import theme
|
||||
from dashboard.components import helpers as h
|
||||
|
||||
|
||||
def chip(text: str) -> str:
|
||||
return f'<span class="mt-chip">{escape(text)}</span>'
|
||||
|
||||
|
||||
def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str:
|
||||
tip_attr = f' data-tip="{escape(tip)}"' if tip else ""
|
||||
cls = " mt-tip" if tip else ""
|
||||
lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon
|
||||
return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>'
|
||||
|
||||
|
||||
def bar(fraction: float | None, tone: str) -> str:
|
||||
pct = 0 if fraction is None else max(0, min(100, round(fraction * 100)))
|
||||
return f'<div class="mt-bar"><span style="width:{pct}%;background:{theme.TONES[tone].solid}"></span></div>'
|
||||
|
||||
|
||||
def page_header(title: str, subtitle: str = "", kicker: str = "") -> str:
|
||||
k = f'<div class="mt-kicker">{escape(kicker)}</div>' if kicker else ""
|
||||
s = f'<div class="mt-sub">{subtitle}</div>' if subtitle else ""
|
||||
return f'<div class="mt-head"><div>{k}<h1>{escape(title)}</h1>{s}</div></div>'
|
||||
|
||||
|
||||
def kpi_tiles(k: h.Kpis) -> str:
|
||||
tiles = [
|
||||
("На сопровождении", str(k.total), "пациентов в кабинете", theme.TONES["blue"].solid),
|
||||
("🔴 Красная зона", str(k.red), "острый сигнал — связаться сегодня", theme.TONES["red"].solid),
|
||||
("🟡 Жёлтая зона", str(k.yellow), "врачу ≤ 24 ч или к визиту", theme.TONES["yellow"].solid),
|
||||
("Риск прерывания терапии", str(k.high_risk), "высокий индекс (правила, не ML)", theme.TONES["red"].solid),
|
||||
]
|
||||
body = "".join(
|
||||
f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>'
|
||||
f'<div class="val">{val}</div><div class="hint">{escape(hint)}</div></div>'
|
||||
for lbl, val, hint, accent in tiles
|
||||
)
|
||||
return f'<div class="mt-kpis">{body}</div>'
|
||||
|
||||
|
||||
LIST_COLUMNS = ["Пациент", "Терапия", "PHQ-9", "PCL-5", "Приём 7 дн.", "Риск прерывания", "Последнее событие", "Алерты"]
|
||||
|
||||
|
||||
def list_header() -> str:
|
||||
return '<div class="mt-cols">' + "".join(f"<div>{c}</div>" for c in LIST_COLUMNS) + "</div>"
|
||||
|
||||
|
||||
def _score(value: int | None, trend: str) -> str:
|
||||
if value is None:
|
||||
return '<span class="mt-dim">—</span>'
|
||||
t = h.trend_view(trend)
|
||||
return (
|
||||
f'<span class="mt-num">{value}</span> '
|
||||
f'<span class="mt-tip" data-tip="{escape(t.hint)}" style="color:{theme.TONES[t.tone].ink};font-weight:700">'
|
||||
f"{t.text}</span>"
|
||||
)
|
||||
|
||||
|
||||
def patient_row(p: PatientListItem) -> str:
|
||||
"""One row of the patient list; the grid matches `list_header()`."""
|
||||
status = h.status_view(p.status_color)
|
||||
tone = theme.TONES[status.tone]
|
||||
chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
|
||||
adh = h.adherence_view(p.adherence_7d)
|
||||
risk = h.risk_view(p.discontinuation_risk)
|
||||
if p.open_alerts:
|
||||
count_style = f"background:{tone.tint};color:{tone.ink}"
|
||||
else:
|
||||
count_style = f"background:{theme.TONES['grey'].tint};color:{theme.INK_3}"
|
||||
return (
|
||||
'<div class="mt-row">'
|
||||
f'<div><div class="mt-name"><span class="mt-dot mt-tip" data-tip="{escape(status.text + " — " + status.hint)}"'
|
||||
f' style="background:{tone.solid}"></span>{escape(p.display_name)}</div>'
|
||||
f'<div class="mt-meta">{h.age_label(p.age)} · {chips}</div></div>'
|
||||
f'<div>{escape(p.drug or "—")}<div class="mt-meta">день терапии {p.therapy_day}</div></div>'
|
||||
f"<div>{_score(p.phq9_last, p.trend)}</div>"
|
||||
f"<div>{_score(p.pcl5_last, p.trend)}</div>"
|
||||
f'<div><span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
|
||||
f"<div>{badge(risk.text, risk.tone, risk.hint)}</div>"
|
||||
f'<div class="mt-event">{escape(p.last_event or "—")}</div>'
|
||||
f'<div><span class="mt-count" style="{count_style}">{p.open_alerts}</span></div>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str:
|
||||
lead = '<span class="mt-pulse"></span>' if pulse else ""
|
||||
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
|
||||
|
||||
|
||||
def footer() -> None:
|
||||
st.html(
|
||||
'<div class="mt-footer">Прототип на синтетических данных · MedITron · '
|
||||
"ИИ не ставит диагнозы и не назначает лечение — решения принимает врач</div>"
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Doctor's patient list ("red zones", idea 2) and, with `?patient=<token>`, the patient card."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from dashboard.components import helpers as h
|
||||
from dashboard.components import patient_card, state, ui
|
||||
|
||||
NAV_ICON = ":material/groups:"
|
||||
|
||||
COLOR_FILTERS = {
|
||||
"Все": None,
|
||||
"🔴 Красные": "red",
|
||||
"🟡 Жёлтые": "yellow",
|
||||
"⚪ Нет ответа": "grey",
|
||||
"🟢 Стабильные": "green",
|
||||
}
|
||||
|
||||
|
||||
def _open(token: str) -> None:
|
||||
st.query_params["patient"] = token
|
||||
|
||||
|
||||
def _close() -> None:
|
||||
st.query_params.pop("patient", None)
|
||||
|
||||
|
||||
def render_card(token: str) -> None:
|
||||
st.button("Все пациенты", icon=":material/arrow_back:", type="tertiary", on_click=_close)
|
||||
card = state.call(lambda: state.client().patient_card(token))
|
||||
if card is None:
|
||||
return
|
||||
patient_card.render(card, state.today())
|
||||
|
||||
|
||||
def render_list() -> None:
|
||||
_, doctor_gen = state.doctor_names()
|
||||
today = state.today()
|
||||
patients = state.call(lambda: state.client().patients())
|
||||
subtitle = "Пациенты на сопровождении между визитами — сначала те, кому нужно внимание сегодня"
|
||||
if today:
|
||||
subtitle += f" · данные на {h.short_date(today)}"
|
||||
|
||||
head, action = st.columns([4, 1.4], vertical_alignment="bottom")
|
||||
with head:
|
||||
st.html(ui.page_header(f"Кабинет {doctor_gen}", subtitle, kicker="Красные зоны"))
|
||||
with action:
|
||||
if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"):
|
||||
st.switch_page(state.CONNECT_PAGE)
|
||||
if patients is None:
|
||||
return
|
||||
|
||||
items = h.sort_patients(patients)
|
||||
st.html(ui.kpi_tiles(h.kpis(items)))
|
||||
|
||||
f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom")
|
||||
with f1:
|
||||
choice = st.segmented_control(
|
||||
"Статус", list(COLOR_FILTERS), default="Все", key="status_filter", label_visibility="collapsed"
|
||||
)
|
||||
with f2:
|
||||
query = st.text_input(
|
||||
"Поиск", placeholder="Поиск по имени или препарату", key="patient_query", label_visibility="collapsed"
|
||||
)
|
||||
shown = h.filter_patients(items, COLOR_FILTERS.get(choice or "Все"), query or "")
|
||||
|
||||
head_cols = st.columns([11, 1.5])
|
||||
with head_cols[0]:
|
||||
st.html(ui.list_header())
|
||||
if not shown:
|
||||
st.caption("Нет пациентов с выбранным статусом")
|
||||
for p in shown:
|
||||
with st.container(key=f"prow-{p.status_color}-{p.token}"):
|
||||
row, btn = st.columns([11, 1.5], vertical_alignment="center")
|
||||
with row:
|
||||
st.html(ui.patient_row(p))
|
||||
with btn:
|
||||
st.button(
|
||||
"Открыть",
|
||||
key=f"open-{p.token}",
|
||||
on_click=_open,
|
||||
args=(p.token,),
|
||||
type="primary" if p.status_color == "red" else "secondary",
|
||||
width="stretch",
|
||||
)
|
||||
st.caption(
|
||||
"Цвет — уровень открытых алертов (ADR-006): 🔴 кризис · 🟡 высокий/умеренный · ⚪ только инфо · 🟢 нет. "
|
||||
"Риск прерывания — индекс правил (пропуски, побочки, намерение бросить, низкое настроение), не ML."
|
||||
)
|
||||
|
||||
|
||||
token = st.query_params.get("patient")
|
||||
if token:
|
||||
render_card(token)
|
||||
else:
|
||||
render_list()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""«Подключить пациента»: after the in-person visit the doctor enters the prescription → one-time code + QR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from html import escape
|
||||
|
||||
import qrcode
|
||||
import streamlit as st
|
||||
|
||||
from app.api.schemas import ConnectPatientIn, ConnectPatientOut
|
||||
from dashboard.components import helpers as h
|
||||
from dashboard.components import state, ui
|
||||
|
||||
NAV_ICON = ":material/person_add:"
|
||||
|
||||
TIME_OPTIONS = ["07:00", "08:00", "09:00", "10:00", "12:00", "14:00", "18:00", "20:00", "21:00", "22:00"]
|
||||
MODULE_OPTIONS = ["veteran", "depression", "family"]
|
||||
DEMO = {
|
||||
"cp_name": "Андрей С.",
|
||||
"cp_year": 1992,
|
||||
"cp_sex": "м",
|
||||
"cp_drug": "сертралин",
|
||||
"cp_dose": "50 мг",
|
||||
"cp_times": ["08:00"],
|
||||
"cp_instr": "утром после еды",
|
||||
"cp_modules": ["veteran", "depression"],
|
||||
}
|
||||
|
||||
|
||||
def qr_png(data: str) -> bytes:
|
||||
img = qrcode.make(data, box_size=8, border=2)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _defaults(today: date) -> None:
|
||||
ss = st.session_state
|
||||
ss.setdefault("cp_name", "")
|
||||
ss.setdefault("cp_year", 1990)
|
||||
ss.setdefault("cp_sex", "м")
|
||||
ss.setdefault("cp_snils", h.synthetic_snils())
|
||||
ss.setdefault("cp_drug", "сертралин")
|
||||
ss.setdefault("cp_times", ["08:00"])
|
||||
ss.setdefault("cp_instr", "утром после еды")
|
||||
ss.setdefault("cp_visit", today + timedelta(days=28))
|
||||
ss.setdefault("cp_modules", ["veteran"])
|
||||
|
||||
|
||||
def _fill_demo(today: date) -> None:
|
||||
st.session_state.update(DEMO)
|
||||
st.session_state["cp_snils"] = h.synthetic_snils()
|
||||
st.session_state["cp_visit"] = today + timedelta(days=28)
|
||||
|
||||
|
||||
def _new_snils() -> None:
|
||||
st.session_state["cp_snils"] = h.synthetic_snils()
|
||||
|
||||
|
||||
def _reset() -> None:
|
||||
st.session_state.pop("cp_result", None)
|
||||
for k in [*DEMO, "cp_snils", "cp_visit", "cp_consent"]:
|
||||
st.session_state.pop(k, None)
|
||||
|
||||
|
||||
def _result(res: ConnectPatientOut, name: str) -> None:
|
||||
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
|
||||
with st.container(border=True):
|
||||
st.html(
|
||||
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
|
||||
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
|
||||
"согласие и начнёт сопровождение. Код одноразовый.</div>"
|
||||
)
|
||||
c1, c2 = st.columns([1, 1.4], vertical_alignment="center")
|
||||
with c1:
|
||||
st.image(qr_png(link), width=220, caption="QR для подключения")
|
||||
with c2:
|
||||
st.html(f'<div class="mt-panel-title">Код подключения</div><div class="mt-code">{escape(res.code)}</div>')
|
||||
st.link_button("Открыть ссылку", link, icon=":material/open_in_new:")
|
||||
st.code(link, language=None)
|
||||
b1, b2 = st.columns(2)
|
||||
with b1:
|
||||
if st.button("Карточка пациента", icon=":material/person:", width="stretch"):
|
||||
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": res.token})
|
||||
with b2:
|
||||
st.button("Подключить ещё", icon=":material/add:", on_click=_reset, width="stretch")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
today = state.today() or date.today()
|
||||
_defaults(today)
|
||||
st.html(
|
||||
ui.page_header(
|
||||
"Подключить пациента",
|
||||
"После очного приёма: назначение уходит в МИС (мок), пациент получает код и начинает сопровождение. "
|
||||
"Только синтетические данные.",
|
||||
kicker="Кабинет врача",
|
||||
)
|
||||
)
|
||||
res: ConnectPatientOut | None = st.session_state.get("cp_result")
|
||||
if res is not None:
|
||||
_result(res, st.session_state.get("cp_result_name", ""))
|
||||
return
|
||||
|
||||
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
|
||||
|
||||
left, right = st.columns(2, gap="large")
|
||||
with left, st.container(border=True):
|
||||
st.html('<div class="mt-panel-title">Пациент</div>')
|
||||
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
|
||||
a, b = st.columns(2)
|
||||
with a:
|
||||
st.number_input("Год рождения", min_value=1940, max_value=today.year - 18, step=1, key="cp_year")
|
||||
with b:
|
||||
st.radio("Пол", ["м", "ж"], key="cp_sex", horizontal=True)
|
||||
s1, s2 = st.columns([3, 1], vertical_alignment="bottom")
|
||||
with s1:
|
||||
st.text_input("СНИЛС (синтетический)", key="cp_snils")
|
||||
with s2:
|
||||
st.button("Новый", icon=":material/refresh:", on_click=_new_snils, width="stretch")
|
||||
st.pills(
|
||||
"Модули сопровождения",
|
||||
MODULE_OPTIONS,
|
||||
selection_mode="multi",
|
||||
key="cp_modules",
|
||||
format_func=lambda m: h.MODULE_LABELS[m],
|
||||
)
|
||||
with right, st.container(border=True):
|
||||
st.html('<div class="mt-panel-title">Назначение</div>')
|
||||
d1, d2 = st.columns([1.4, 1])
|
||||
with d1:
|
||||
drug = st.selectbox("Препарат (МНН)", list(h.DRUGS), key="cp_drug")
|
||||
with d2:
|
||||
doses = h.DRUGS[drug]
|
||||
if st.session_state.get("cp_dose") not in doses:
|
||||
st.session_state["cp_dose"] = doses[1] if len(doses) > 1 else doses[0]
|
||||
st.selectbox("Доза", doses, key="cp_dose")
|
||||
st.multiselect("Время приёма", TIME_OPTIONS, key="cp_times")
|
||||
st.text_input("Указания", key="cp_instr")
|
||||
st.date_input("Следующий визит", key="cp_visit", min_value=today, format="DD.MM.YYYY")
|
||||
|
||||
consent = st.checkbox(
|
||||
"Пациент подписал информированное добровольное согласие на дистанционное наблюдение (323-ФЗ, ст. 20, 36.2)",
|
||||
key="cp_consent",
|
||||
)
|
||||
if not st.button("Подключить и получить код", type="primary", icon=":material/qr_code_2:"):
|
||||
return
|
||||
|
||||
ss = st.session_state
|
||||
errors = []
|
||||
if not ss["cp_name"].strip():
|
||||
errors.append("укажите имя")
|
||||
if not h.is_valid_snils(ss["cp_snils"]):
|
||||
errors.append("СНИЛС не проходит контрольную сумму — нажмите «Новый»")
|
||||
if not ss["cp_times"]:
|
||||
errors.append("выберите время приёма")
|
||||
if not ss["cp_modules"]:
|
||||
errors.append("выберите хотя бы один модуль")
|
||||
if not consent:
|
||||
errors.append("нужно согласие пациента")
|
||||
if errors:
|
||||
st.error("Проверьте форму: " + "; ".join(errors))
|
||||
return
|
||||
|
||||
body = ConnectPatientIn(
|
||||
doctor_id=state.client().doctor_id,
|
||||
display_name=ss["cp_name"].strip(),
|
||||
birth_year=int(ss["cp_year"]),
|
||||
sex="m" if ss["cp_sex"] == "м" else "f",
|
||||
snils=ss["cp_snils"],
|
||||
drug=ss["cp_drug"],
|
||||
dose=ss["cp_dose"],
|
||||
times=sorted(ss["cp_times"]),
|
||||
instructions=ss["cp_instr"],
|
||||
next_visit=ss["cp_visit"],
|
||||
modules=ss["cp_modules"],
|
||||
)
|
||||
out = state.call(lambda: state.client().connect(body))
|
||||
if out is not None:
|
||||
ss["cp_result"] = out
|
||||
ss["cp_result_name"] = body.display_name
|
||||
st.rerun()
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Visual system of the doctor dashboard: status palette (ADR-006), text tokens, CSS and the plotly template.
|
||||
|
||||
Status colours are reserved for alert state (red = acute, yellow = high/moderate, green = nothing open,
|
||||
grey = info only). They always ship together with an icon and a text label, never colour alone.
|
||||
Chart series use a separate neutral blue so that a line is never confused with a status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
Tone = Literal["red", "yellow", "green", "grey", "blue"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToneSpec:
|
||||
solid: str # marks, stripes, dots
|
||||
ink: str # text on light surface (WCAG AA on white / on the tint)
|
||||
tint: str # badge / banner background
|
||||
icon: str
|
||||
label: str
|
||||
|
||||
|
||||
TONES: dict[str, ToneSpec] = {
|
||||
"red": ToneSpec("#D64545", "#A32D2D", "#FCEBEB", "🔴", "Красная зона"),
|
||||
"yellow": ToneSpec("#E0A800", "#7F5A00", "#FFF5D6", "🟡", "Жёлтая зона"),
|
||||
"green": ToneSpec("#2E9E5B", "#1D6E3F", "#E6F4EC", "🟢", "Стабильно"),
|
||||
"grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"),
|
||||
"blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""),
|
||||
}
|
||||
|
||||
INK = "#1B1F24"
|
||||
INK_2 = "#4B535C"
|
||||
INK_3 = "#7A828C"
|
||||
SURFACE = "#FFFFFF"
|
||||
CANVAS = "#F6F7F9"
|
||||
LINE = "#E3E6EA"
|
||||
SERIES = TONES["blue"].solid
|
||||
FONT_STACK = "'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif"
|
||||
|
||||
CSS = f"""
|
||||
<style>
|
||||
:root {{
|
||||
--ink: {INK}; --ink-2: {INK_2}; --ink-3: {INK_3}; --line: {LINE}; --surface: {SURFACE}; --canvas: {CANVAS};
|
||||
--red: {TONES["red"].solid}; --red-ink: {TONES["red"].ink}; --red-tint: {TONES["red"].tint};
|
||||
--yellow: {TONES["yellow"].solid}; --yellow-ink: {TONES["yellow"].ink}; --yellow-tint: {TONES["yellow"].tint};
|
||||
--green: {TONES["green"].solid}; --green-ink: {TONES["green"].ink}; --green-tint: {TONES["green"].tint};
|
||||
--grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint};
|
||||
--blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint};
|
||||
}}
|
||||
.stApp {{ background: var(--canvas); }}
|
||||
.block-container {{ padding-top: 2.2rem; max-width: 1320px; }}
|
||||
h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }}
|
||||
|
||||
/* ---------- page header ---------- */
|
||||
.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap;
|
||||
margin: 0 0 6px 0; }}
|
||||
.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }}
|
||||
.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }}
|
||||
.mt-head h1 {{ font-size: 1.75rem; font-weight: 700; margin: 0; padding: 0; line-height: 1.2; }}
|
||||
.mt-sub {{ color: var(--ink-2); font-size: .95rem; margin-top: 4px; }}
|
||||
.mt-kicker {{ color: var(--ink-3); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
font-weight: 600; }}
|
||||
|
||||
/* ---------- KPI tiles ---------- */
|
||||
.mt-kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin: 14px 0 6px; }}
|
||||
.mt-kpi {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
|
||||
position: relative; overflow: hidden; }}
|
||||
.mt-kpi::before {{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background: var(--kpi-accent,
|
||||
var(--line)); }}
|
||||
.mt-kpi .lbl {{ color: var(--ink-2); font-size: .85rem; font-weight: 500; }}
|
||||
.mt-kpi .val {{ color: var(--ink); font-size: 2rem; font-weight: 650; line-height: 1.15; margin-top: 4px; }}
|
||||
.mt-kpi .hint {{ color: var(--ink-3); font-size: .78rem; margin-top: 2px; }}
|
||||
|
||||
/* ---------- patient rows ---------- */
|
||||
.mt-cols, .mt-row {{ display:grid; align-items:center; gap: 14px;
|
||||
grid-template-columns: minmax(0,2fr) minmax(0,1.5fr) 58px 58px minmax(0,1fr) minmax(0,1.2fr) minmax(0,2fr) 40px; }}
|
||||
.mt-row > div {{ min-width: 0; overflow-wrap: anywhere; }}
|
||||
.mt-cols {{ color: var(--ink-3); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em;
|
||||
font-weight: 600; padding: 0 0 0 21px; margin-top: 8px; }}
|
||||
.mt-row {{ font-size: .9rem; color: var(--ink); }}
|
||||
.mt-name {{ font-weight: 600; font-size: 1rem; display:flex; align-items:center; gap:8px; }}
|
||||
.mt-meta {{ color: var(--ink-3); font-size: .8rem; margin-top: 3px; }}
|
||||
.mt-dim {{ color: var(--ink-3); }}
|
||||
.mt-num {{ font-variant-numeric: tabular-nums; font-weight: 600; }}
|
||||
.mt-event {{ color: var(--ink-2); font-size: .85rem; line-height: 1.3; }}
|
||||
.mt-dot {{ width:10px; height:10px; border-radius:50%; display:inline-block; flex: none; }}
|
||||
div[class*="st-key-prow-"] {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 10px 12px 10px 16px; border-left-width: 5px; transition: box-shadow .15s ease; }}
|
||||
div[class*="st-key-prow-"]:hover {{ box-shadow: 0 2px 10px rgba(20,30,40,.08); }}
|
||||
div[class*="st-key-prow-red"] {{ border-left-color: var(--red); background: linear-gradient(90deg, var(--red-tint) 0,
|
||||
var(--surface) 22%); }}
|
||||
div[class*="st-key-prow-yellow"] {{ border-left-color: var(--yellow); }}
|
||||
div[class*="st-key-prow-green"] {{ border-left-color: var(--green); }}
|
||||
div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
|
||||
|
||||
/* ---------- chips, badges, bars ---------- */
|
||||
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
|
||||
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
|
||||
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.8rem; font-weight:600; padding: 3px 10px;
|
||||
border-radius: 999px; white-space: nowrap; }}
|
||||
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
|
||||
.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }}
|
||||
.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }}
|
||||
.mt-badge.t-grey {{ background: var(--grey-tint); color: var(--grey-ink); }}
|
||||
.mt-badge.t-blue {{ background: var(--blue-tint); color: var(--blue-ink); }}
|
||||
.mt-bar {{ height: 6px; background: var(--grey-tint); border-radius: 999px; overflow: hidden; margin-top: 5px; }}
|
||||
.mt-bar > span {{ display:block; height: 100%; border-radius: 999px; }}
|
||||
.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center;
|
||||
justify-content:center; font-weight: 700; font-size: .85rem; }}
|
||||
.mt-tip {{ position: relative; cursor: help; }}
|
||||
.mt-tip:hover::after {{ content: attr(data-tip); position: absolute; z-index: 50; left: 0; top: 120%;
|
||||
min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff;
|
||||
font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.18); }}
|
||||
|
||||
/* ---------- banners & panels ---------- */
|
||||
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
|
||||
border: 1px solid; margin-bottom: 6px; }}
|
||||
.mt-banner.t-red {{ background: var(--red-tint); border-color: #F2C4C4; color: var(--red-ink); }}
|
||||
.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }}
|
||||
.mt-banner b {{ color: inherit; }}
|
||||
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
|
||||
box-shadow: 0 0 0 0 rgba(214,69,69,.6); animation: mtpulse 1.6s infinite; }}
|
||||
@keyframes mtpulse {{ 0% {{ box-shadow: 0 0 0 0 rgba(214,69,69,.55); }} 70% {{ box-shadow: 0 0 0 10px
|
||||
rgba(214,69,69,0); }} 100% {{ box-shadow: 0 0 0 0 rgba(214,69,69,0); }} }}
|
||||
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
|
||||
color: var(--ink-3); margin: 2px 0 8px; }}
|
||||
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
|
||||
.mt-alert.t-red {{ border-left-color: var(--red); }}
|
||||
.mt-alert.t-yellow {{ border-left-color: var(--yellow); }}
|
||||
.mt-alert.t-grey {{ border-left-color: var(--grey); }}
|
||||
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
|
||||
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
|
||||
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
|
||||
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink);
|
||||
line-height: 1.5; font-size: .93rem; }}
|
||||
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }}
|
||||
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
|
||||
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
|
||||
|
||||
/* ---------- dose strip ---------- */
|
||||
.mt-doses {{ display:grid; gap: 4px; align-items:center; }}
|
||||
.mt-doses .cell {{ height: 26px; border-radius: 6px; display:flex; align-items:center; justify-content:center;
|
||||
font-size: .72rem; font-weight: 700; color: #fff; }}
|
||||
.mt-doses .c-taken {{ background: var(--green); }}
|
||||
.mt-doses .c-missed {{ background: var(--red); }}
|
||||
.mt-doses .c-skipped {{ background: var(--yellow); color: #3D2B00; }}
|
||||
.mt-doses .c-pending {{ background: var(--surface); border: 1.5px dashed var(--grey); color: var(--ink-3); }}
|
||||
.mt-doses .c-none {{ background: transparent; }}
|
||||
.mt-doses .lab {{ color: var(--ink-3); font-size: .72rem; text-align:center; font-variant-numeric: tabular-nums; }}
|
||||
.mt-legend {{ display:flex; gap: 14px; flex-wrap: wrap; color: var(--ink-2); font-size: .8rem; margin-top: 8px; }}
|
||||
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
|
||||
vertical-align: -1px; }}
|
||||
|
||||
/* ---------- connect result ---------- */
|
||||
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
|
||||
font-variant-numeric: tabular-nums; }}
|
||||
.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px;
|
||||
border-top: 1px solid var(--line); padding-top: 12px; }}
|
||||
|
||||
@media (max-width: 900px) {{
|
||||
.mt-kpis {{ grid-template-columns: repeat(2, minmax(0,1fr)); }}
|
||||
.mt-cols {{ display: none; }}
|
||||
.mt-row {{ grid-template-columns: 1fr 1fr; }}
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def plotly_layout(**overrides: object) -> dict[str, object]:
|
||||
"""Shared plotly layout: recessive grid, no chart junk, the same font as the page."""
|
||||
layout: dict[str, object] = {
|
||||
"font": {"family": FONT_STACK, "size": 12, "color": INK_2},
|
||||
"paper_bgcolor": "rgba(0,0,0,0)",
|
||||
"plot_bgcolor": "rgba(0,0,0,0)",
|
||||
"margin": {"l": 8, "r": 8, "t": 36, "b": 8},
|
||||
"hoverlabel": {"bgcolor": INK, "font": {"color": "#FFFFFF", "family": FONT_STACK}},
|
||||
"showlegend": False,
|
||||
"hovermode": "x unified",
|
||||
}
|
||||
layout.update(overrides)
|
||||
return layout
|
||||
|
||||
|
||||
AXIS = {"showgrid": True, "gridcolor": LINE, "zeroline": False, "linecolor": LINE, "tickcolor": LINE, "ticks": ""}
|
||||
PLOTLY_CONFIG = {"displayModeBar": False, "responsive": True}
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Dashboard HTTP client: paths, parsing via the frozen schemas, graceful errors (no tracebacks to the UI)."""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import ConnectPatientIn
|
||||
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
|
||||
|
||||
|
||||
def _client(handler) -> ApiClient:
|
||||
return ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _json(models) -> list | dict:
|
||||
if isinstance(models, list):
|
||||
return [m.model_dump(mode="json") for m in models]
|
||||
return models.model_dump(mode="json")
|
||||
|
||||
|
||||
def test_base_url_gets_api_prefix_once():
|
||||
assert ApiClient("http://x:8000/").base_url == "http://x:8000/api/v1"
|
||||
assert ApiClient("http://x:8000/api/v1").base_url == "http://x:8000/api/v1"
|
||||
|
||||
|
||||
def test_doctor_id_from_env(monkeypatch):
|
||||
monkeypatch.setenv("DOCTOR_ID", "doc-other")
|
||||
assert ApiClient("http://x").doctor_id == "doc-other"
|
||||
monkeypatch.delenv("DOCTOR_ID")
|
||||
assert ApiClient("http://x").doctor_id == "doc-smirnova"
|
||||
|
||||
|
||||
def test_patients_and_card_are_parsed():
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(req.url))
|
||||
if req.url.path.endswith("/patients"):
|
||||
return httpx.Response(200, json=_json(fixtures.PATIENTS))
|
||||
return httpx.Response(200, json=_json(fixtures.andrey_card()))
|
||||
|
||||
c = _client(handler)
|
||||
patients = c.patients()
|
||||
assert patients[0].display_name == "Андрей С."
|
||||
card = c.patient_card("tok-andrey")
|
||||
assert card.patient.status_color == "red"
|
||||
assert seen[0] == "http://api.test/api/v1/doctors/doc-smirnova/patients"
|
||||
assert seen[1] == "http://api.test/api/v1/patients/tok-andrey?doctor_id=doc-smirnova"
|
||||
|
||||
|
||||
def test_alerts_params_and_ack_body():
|
||||
captured: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
if req.method == "POST":
|
||||
captured["body"] = json.loads(req.content)
|
||||
return httpx.Response(200, json=_json(fixtures.all_alerts()[0]))
|
||||
captured["params"] = dict(req.url.params)
|
||||
return httpx.Response(200, json=_json(fixtures.all_alerts()))
|
||||
|
||||
c = _client(handler)
|
||||
assert len(c.alerts(since_id=2, open_only=True)) == 4
|
||||
assert captured["params"] == {"since_id": "2", "open_only": "true"}
|
||||
c.ack(3)
|
||||
assert captured["body"] == {"doctor_id": "doc-smirnova"}
|
||||
|
||||
|
||||
def test_connect_posts_payload():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(req.content)
|
||||
assert body["drug"] == "сертралин" and body["times"] == ["08:00"]
|
||||
return httpx.Response(200, json={"token": "t", "code": "DEMO-1", "deep_link": "https://t.me/x?start=DEMO-1"})
|
||||
|
||||
body = ConnectPatientIn(
|
||||
doctor_id="doc-smirnova",
|
||||
display_name="Андрей С.",
|
||||
birth_year=1992,
|
||||
sex="m",
|
||||
snils="000-000-000 00",
|
||||
drug="сертралин",
|
||||
dose="50 мг",
|
||||
)
|
||||
assert _client(handler).connect(body).code == "DEMO-1"
|
||||
|
||||
|
||||
def test_connection_error_becomes_unavailable():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused", request=req)
|
||||
|
||||
with pytest.raises(ApiUnavailable) as exc:
|
||||
_client(handler).patients()
|
||||
assert "API недоступен" in exc.value.message
|
||||
|
||||
|
||||
def test_timeout_becomes_unavailable():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("slow", request=req)
|
||||
|
||||
with pytest.raises(ApiUnavailable):
|
||||
_client(handler).alerts()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("status", "fragment"), [(404, "Не найдено"), (500, "500"), (422, "422")])
|
||||
def test_http_errors(status, fragment):
|
||||
c = _client(lambda req: httpx.Response(status, json={"detail": "x"}))
|
||||
with pytest.raises(ApiError) as exc:
|
||||
c.patient_card("tok-x")
|
||||
assert fragment in exc.value.message
|
||||
assert exc.value.status == status
|
||||
assert not isinstance(exc.value, ApiUnavailable)
|
||||
|
||||
|
||||
def test_bad_payload_is_api_error():
|
||||
c = _client(lambda req: httpx.Response(200, json=[{"token": "only"}]))
|
||||
with pytest.raises(ApiError, match="неожиданный"):
|
||||
c.patients()
|
||||
c = _client(lambda req: httpx.Response(200, content=b"<html>"))
|
||||
with pytest.raises(ApiError):
|
||||
c.patients()
|
||||
|
||||
|
||||
def test_clock_failure_is_none():
|
||||
c = _client(lambda req: httpx.Response(503))
|
||||
assert c.clock() is None
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Pure dashboard logic: ordering, labels, dose strip, scales config, SNILS, chart builders, page discovery."""
|
||||
|
||||
import random
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, SeriesPoint
|
||||
from dashboard.app import discover_pages, slugify
|
||||
from dashboard.components import charts, ui
|
||||
from dashboard.components import helpers as h
|
||||
|
||||
TZ = ZoneInfo("Europe/Moscow")
|
||||
|
||||
|
||||
def _risk(score: int, level: str = "low", factors: list[str] | None = None) -> DiscontinuationRisk:
|
||||
return DiscontinuationRisk(score=score, level=level, factors=factors or [])
|
||||
|
||||
|
||||
def test_sort_red_yellow_grey_green_then_risk():
|
||||
items = list(reversed(fixtures.PATIENTS))
|
||||
items.append(fixtures.PATIENTS[1].model_copy(update={"token": "g", "display_name": "Г", "status_color": "grey"}))
|
||||
out = h.sort_patients(items)
|
||||
colors = [p.status_color for p in out]
|
||||
assert colors == sorted(colors, key=h.STATUS_ORDER.__getitem__)
|
||||
assert out[0].token == "tok-andrey"
|
||||
yellows = [p for p in out if p.status_color == "yellow"]
|
||||
assert yellows[0].display_name == "Сергей П." # highest discontinuation risk among yellow
|
||||
assert [p.discontinuation_risk.score for p in yellows] == sorted(
|
||||
(p.discontinuation_risk.score for p in yellows), reverse=True
|
||||
)
|
||||
|
||||
|
||||
def test_filter_and_kpis():
|
||||
items = fixtures.PATIENTS
|
||||
assert {p.status_color for p in h.filter_patients(items, "yellow")} == {"yellow"}
|
||||
assert [p.display_name for p in h.filter_patients(items, query="андр")] == ["Андрей С."]
|
||||
assert len(h.filter_patients(items, query="пароксетин")) == 1
|
||||
k = h.kpis(items)
|
||||
assert (k.total, k.red, k.yellow, k.green, k.grey, k.high_risk) == (8, 1, 4, 3, 0, 1)
|
||||
|
||||
|
||||
def test_trend_arrows():
|
||||
assert h.trend_view("up").text == "↑" and h.trend_view("up").tone == "red"
|
||||
assert h.trend_view("down").text == "↓" and h.trend_view("down").tone == "green"
|
||||
assert h.trend_view("flat").text == "→"
|
||||
assert h.trend_view("unknown").tone == "grey"
|
||||
|
||||
|
||||
def test_risk_badge_and_tooltip():
|
||||
v = h.risk_view(_risk(70, "high", ["пропуски 3 из 7", "нет ответа 2 дня"]))
|
||||
assert v.text == "Высокий · 70" and v.tone == "red"
|
||||
assert "• пропуски 3 из 7\n• нет ответа 2 дня" in v.hint
|
||||
assert h.risk_view(_risk(35, "medium")).tone == "yellow"
|
||||
assert "значимых факторов нет" in h.risk_view(_risk(5)).hint
|
||||
|
||||
|
||||
def test_adherence_thresholds():
|
||||
assert h.adherence_view(None).text == "—"
|
||||
assert h.adherence_view(1.0).tone == "green"
|
||||
assert h.adherence_view(0.8).tone == "green"
|
||||
assert h.adherence_view(0.7).tone == "yellow"
|
||||
assert h.adherence_view(0.57) == h.View("57%", "red")
|
||||
|
||||
|
||||
def test_level_tone_matches_adr006():
|
||||
assert h.level_tone("acute") == "red"
|
||||
assert h.level_tone("high") == h.level_tone("moderate") == "yellow"
|
||||
assert h.level_tone("info") == "grey"
|
||||
|
||||
|
||||
def test_severity_bands_come_from_config():
|
||||
bands = h.severity_bands("phq9")
|
||||
assert [b.lo for b in bands] == [0, 5, 10, 15, 20]
|
||||
assert h.severity_label("phq9", 18) == "Умеренно-тяжёлая"
|
||||
assert h.severity_label("pcl5", 44) == "Вероятное ПТСР"
|
||||
assert h.severity_label("pcl5", None) == ""
|
||||
assert h.severity_bands("no_such_scale") == ()
|
||||
|
||||
|
||||
def test_last_and_prev():
|
||||
card = fixtures.andrey_card()
|
||||
assert h.last_and_prev(card.series, "phq9") == (18, 13)
|
||||
assert h.last_and_prev(card.series, "gad7") == (None, None)
|
||||
|
||||
|
||||
def test_dose_grid_fills_gaps_and_counts():
|
||||
d0 = datetime(2026, 9, 21, 8, tzinfo=TZ)
|
||||
doses = [
|
||||
DoseOut(planned_at=d0, drug="сертралин", status="taken"),
|
||||
DoseOut(planned_at=d0 + timedelta(hours=12), drug="сертралин", status="missed"),
|
||||
DoseOut(planned_at=d0 + timedelta(days=2), drug="сертралин", status="skipped"),
|
||||
DoseOut(planned_at=d0 + timedelta(days=3), drug="сертралин", status="pending"),
|
||||
]
|
||||
g = h.dose_grid(doses)
|
||||
assert g.days == [date(2026, 9, 21) + timedelta(days=i) for i in range(4)]
|
||||
assert g.slots == ["08:00", "20:00"]
|
||||
assert g.cells[("20:00", date(2026, 9, 21))] == "missed"
|
||||
assert ("08:00", date(2026, 9, 22)) not in g.cells # no dose planned that day → empty cell
|
||||
assert g.counts == {"taken": 1, "missed": 1, "skipped": 1, "pending": 1}
|
||||
assert g.adherence == 1 / 3 # pending is not counted
|
||||
|
||||
|
||||
def test_dose_grid_empty():
|
||||
g = h.dose_grid([])
|
||||
assert g.days == [] and g.adherence is None
|
||||
|
||||
|
||||
def test_dose_grid_converts_to_moscow_time():
|
||||
utc = datetime(2026, 9, 21, 5, tzinfo=ZoneInfo("UTC"))
|
||||
g = h.dose_grid([DoseOut(planned_at=utc, drug="x", status="taken")])
|
||||
assert g.slots == ["08:00"]
|
||||
|
||||
|
||||
def _alert(i: int, level: str = "moderate", acked: bool = False, hours: int = 0) -> AlertOut:
|
||||
ts = datetime(2026, 10, 3, 9, tzinfo=TZ) + timedelta(hours=hours)
|
||||
return AlertOut(
|
||||
id=i,
|
||||
token="t",
|
||||
patient_name="П",
|
||||
ts=ts,
|
||||
level=level,
|
||||
color=h.level_tone(level),
|
||||
code="c",
|
||||
reason="r",
|
||||
acked_by="d" if acked else None,
|
||||
acked_at=ts if acked else None,
|
||||
)
|
||||
|
||||
|
||||
def test_alert_ordering_new_and_local_acks():
|
||||
alerts = [_alert(1, "moderate", hours=5), _alert(2, "acute", hours=1), _alert(3, "high", acked=True, hours=9)]
|
||||
assert [a.id for a in h.sort_alerts(alerts)] == [2, 1, 3]
|
||||
assert [a.id for a in h.split_new(alerts, {1})] == [2, 3]
|
||||
at = datetime(2026, 10, 3, 22, tzinfo=TZ)
|
||||
out = h.apply_local_acks(alerts, {2: ("doc-smirnova", at)})
|
||||
assert out[1].acked_by == "doc-smirnova" and out[1].acked_at == at
|
||||
assert out[0].acked_at is None
|
||||
|
||||
|
||||
def test_russian_formatting():
|
||||
assert [h.age_label(n) for n in (21, 23, 34, 41, 45, 11, 112)] == [
|
||||
"21 год",
|
||||
"23 года",
|
||||
"34 года",
|
||||
"41 год",
|
||||
"45 лет",
|
||||
"11 лет",
|
||||
"112 лет",
|
||||
]
|
||||
assert h.age_label(0) == "возраст не указан"
|
||||
assert h.absolute_link("/console?code=X", "http://h:8501/") == "http://h:8501/console?code=X"
|
||||
assert h.absolute_link("https://t.me/b?start=X", "http://h") == "https://t.me/b?start=X"
|
||||
today = date(2026, 10, 3)
|
||||
assert h.visit_label(date(2026, 10, 18), today) == "визит 18 окт · через 15 дней"
|
||||
assert h.visit_label(date(2026, 10, 4), today) == "визит 4 окт · через 1 день"
|
||||
assert h.visit_label(today, today) == "визит сегодня"
|
||||
assert "просрочен" in h.visit_label(date(2026, 10, 1), today)
|
||||
assert h.visit_label(None, today) == "визит не назначен"
|
||||
assert h.when_label(datetime(2026, 10, 3, 21, tzinfo=TZ), today) == "сегодня, 21:00"
|
||||
|
||||
|
||||
def test_snils_checksum():
|
||||
assert h.snils_checksum("112233445") == 95 # well-known valid example 112-233-445 95
|
||||
assert h.is_valid_snils("112-233-445 95")
|
||||
assert not h.is_valid_snils("112-233-445 96")
|
||||
rng = random.Random(1)
|
||||
for _ in range(50):
|
||||
s = h.synthetic_snils(rng)
|
||||
assert s.startswith("000-") and h.is_valid_snils(s)
|
||||
|
||||
|
||||
def test_ui_row_escapes_and_shows_values():
|
||||
p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"})
|
||||
html = ui.patient_row(p)
|
||||
assert "<script>" not in html and "<script>" in html
|
||||
assert "Андрей С." in html and "день терапии 13" in html and "↑" in html
|
||||
|
||||
|
||||
def test_scale_figure_marks_unscheduled_and_bands():
|
||||
card = fixtures.andrey_card()
|
||||
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
|
||||
assert fig is not None
|
||||
trace = fig.data[0]
|
||||
assert list(trace.marker.symbol) == ["circle", "circle", "diamond-open"]
|
||||
assert len(fig.layout.shapes) == 5 # severity bands from config/scales/phq9.yaml
|
||||
assert charts.scale_figure([], "phq9", "PHQ-9") is None
|
||||
|
||||
|
||||
def test_wellbeing_figure_small_multiples():
|
||||
card = fixtures.andrey_card()
|
||||
fig = charts.wellbeing_figure(card.series)
|
||||
assert fig is not None and len(fig.data) == 3
|
||||
assert charts.wellbeing_figure([SeriesPoint(day=date(2026, 1, 1), metric="phq9", value=3)]) is None
|
||||
|
||||
|
||||
def test_page_discovery_orders_and_sections(tmp_path: Path):
|
||||
for name in ("1_Пациенты.py", "1b_Подключить_пациента.py", "2_Демо-консоль.py", "3_Сплит-экран.py", "helper.py"):
|
||||
(tmp_path / name).write_text("", encoding="utf-8")
|
||||
(tmp_path / "4_Что-то.py").write_text('NAV_ICON = ":material/star:"\nNAV_SECTION = "Прочее"\n', encoding="utf-8")
|
||||
specs = discover_pages(tmp_path)
|
||||
assert [s.title for s in specs] == ["Пациенты", "Подключить пациента", "Демо-консоль", "Сплит-экран", "Что-то"]
|
||||
assert [s.section for s in specs][:4] == ["Кабинет врача", "Кабинет врача", "Демо", "Демо"]
|
||||
assert specs[4].icon == ":material/star:" and specs[4].section == "Прочее"
|
||||
assert specs[2].url_path == "demo-konsol"
|
||||
assert slugify("Сплит-экран") == "split-ekran"
|
||||
|
||||
|
||||
def test_real_pages_dir_has_doctor_pages():
|
||||
titles = [s.title for s in discover_pages()]
|
||||
assert titles[:2] == ["Пациенты", "Подключить пациента"]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Streamlit AppTest smoke tests of the doctor pages against an in-memory API (fixtures over httpx.MockTransport)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from app.api import fixtures
|
||||
from dashboard.api_client import ApiClient
|
||||
from dashboard.components import state
|
||||
|
||||
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
|
||||
LIST_PAGE = str(PAGES / "1_Пациенты.py")
|
||||
CONNECT_PAGE = str(PAGES / "1b_Подключить_пациента.py")
|
||||
|
||||
|
||||
def _dump(obj):
|
||||
return [o.model_dump(mode="json") for o in obj] if isinstance(obj, list) else obj.model_dump(mode="json")
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.extra_alerts: list = []
|
||||
|
||||
def __call__(self, req: httpx.Request) -> httpx.Response:
|
||||
path = req.url.path.removeprefix("/api/v1")
|
||||
self.calls.append((req.method, path))
|
||||
if path == "/doctors/doc-smirnova/patients":
|
||||
return httpx.Response(200, json=_dump(fixtures.PATIENTS))
|
||||
if path.startswith("/patients/tok-"):
|
||||
card = fixtures.card(path.rsplit("/", 1)[1])
|
||||
return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404)
|
||||
if path == "/doctors/doc-smirnova/alerts":
|
||||
return httpx.Response(200, json=_dump(self.extra_alerts + fixtures.all_alerts()))
|
||||
if path.startswith("/alerts/") and path.endswith("/ack"):
|
||||
alert = next(a for a in fixtures.all_alerts() if a.id == int(path.split("/")[2]))
|
||||
acked = alert.model_copy(update={"acked_by": "doc-smirnova", "acked_at": alert.ts})
|
||||
return httpx.Response(200, json=_dump(acked))
|
||||
if path == "/patients/connect":
|
||||
return httpx.Response(
|
||||
200, json={"token": "tok-new", "code": "DEMO-0001", "deep_link": "https://t.me/b?start=DEMO-0001"}
|
||||
)
|
||||
if path == "/demo/clock":
|
||||
return httpx.Response(200, json={"now": "2026-10-03T21:05:00+03:00", "virtual": True, "offset_days": 0})
|
||||
return httpx.Response(404)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(monkeypatch) -> FakeApi:
|
||||
fake = FakeApi()
|
||||
client = ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(fake))
|
||||
monkeypatch.setattr(state, "client", lambda: client)
|
||||
return fake
|
||||
|
||||
|
||||
def _html(at: AppTest) -> str:
|
||||
return "\n".join(str(el.proto.body) for el in at.get("html"))
|
||||
|
||||
|
||||
def test_list_page_renders_sorted_patients(api):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
html = _html(at)
|
||||
assert "Кабинет д-ра Смирновой Е. А." in html
|
||||
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
|
||||
assert "Высокий · 70" in html # discontinuation-risk badge
|
||||
open_buttons = [b for b in at.button if b.label == "Открыть"]
|
||||
assert len(open_buttons) == len(fixtures.PATIENTS)
|
||||
|
||||
|
||||
def test_list_filter_and_open_card(api):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
at.segmented_control(key="status_filter").set_value("🔴 Красные").run()
|
||||
assert len([b for b in at.button if b.label == "Открыть"]) == 1
|
||||
at.button(key="open-tok-andrey").click().run()
|
||||
assert not at.exception
|
||||
assert at.query_params["patient"] in ("tok-andrey", ["tok-andrey"])
|
||||
html = _html(at)
|
||||
assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html
|
||||
assert "Решения принимает врач" in html
|
||||
assert ("GET", "/patients/tok-andrey") in api.calls
|
||||
|
||||
|
||||
def test_card_ack_posts_and_marks_alert(api):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20)
|
||||
at.query_params["patient"] = "tok-andrey"
|
||||
at.run()
|
||||
assert not at.exception
|
||||
assert at.button(key="ack-3").label == "Подтвердить"
|
||||
at.button(key="ack-3").click().run()
|
||||
assert ("POST", "/alerts/3/ack") in api.calls
|
||||
assert not [b for b in at.button if b.key == "ack-3"] # acked alert has no button any more
|
||||
|
||||
|
||||
def test_api_down_shows_banner(monkeypatch):
|
||||
def refuse(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused", request=req)
|
||||
|
||||
client = ApiClient("http://api.test", transport=httpx.MockTransport(refuse))
|
||||
monkeypatch.setattr(state, "client", lambda: client)
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
assert "API недоступен" in _html(at)
|
||||
|
||||
|
||||
def test_connect_flow_shows_code(api):
|
||||
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
at.button[0].click().run() # «Заполнить демо-данными»
|
||||
at.checkbox(key="cp_consent").check().run()
|
||||
assert at.text_input(key="cp_name").value == "Андрей С."
|
||||
submit = next(b for b in at.button if b.label == "Подключить и получить код")
|
||||
submit.click().run()
|
||||
assert not at.exception
|
||||
assert ("POST", "/patients/connect") in api.calls
|
||||
assert "DEMO-0001" in _html(at)
|
||||
assert at.get("image") # QR image
|
||||
|
||||
|
||||
def test_connect_requires_consent(api):
|
||||
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
|
||||
at.text_input(key="cp_name").set_value("Пётр К.").run()
|
||||
next(b for b in at.button if b.label == "Подключить и получить код").click().run()
|
||||
assert at.error and "согласие" in at.error[0].value
|
||||
assert ("POST", "/patients/connect") not in api.calls
|
||||
|
||||
|
||||
def test_app_entry_navigation_and_acute_banner(api):
|
||||
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
|
||||
assert not at.exception
|
||||
html = _html(at)
|
||||
assert "Кризисный сигнал · Андрей С." in html # open acute alert pinned above every page
|
||||
assert "Кабинет д-ра Смирновой Е. А." in html # default page = patient list
|
||||
assert "Прототип на синтетических данных" in html
|
||||
assert ("GET", "/doctors/doc-smirnova/alerts") in api.calls
|
||||
|
||||
|
||||
def test_new_alert_toasts_once(api):
|
||||
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
|
||||
assert not at.toast # alerts open at session start are not "new"
|
||||
new = fixtures.all_alerts()[0].model_copy(
|
||||
update={"id": 10, "token": "tok-sergey", "patient_name": "Сергей П.", "reason": "C-SSRS: намерение"}
|
||||
)
|
||||
api.extra_alerts.append(new)
|
||||
at.run()
|
||||
assert not at.exception
|
||||
assert any("Сергей П." in t.value for t in at.toast)
|
||||
assert "Кризисный сигнал · Сергей П." in _html(at)
|
||||
at.run()
|
||||
assert not any("Сергей П." in t.value for t in at.toast)
|
||||
Reference in New Issue
Block a user