124 lines
4.7 KiB
Python
124 lines
4.7 KiB
Python
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
|
|
|
|
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:
|
|
|
|
* 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
|
|
|
|
from dashboard import theme
|
|
from dashboard.components import live_alerts, state, ui
|
|
|
|
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
|
|
st.html(theme.CSS)
|
|
st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large")
|
|
|
|
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:
|
|
st.html(
|
|
'<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
|
|
"Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
|
|
)
|
|
st.caption("Прототип на синтетических данных")
|
|
|
|
short, _ = state.doctor_names()
|
|
st.html(ui.workspace_header(short, current.title))
|
|
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()
|