CI / ruff · pytest (pull_request) Failing after 2m33s
- POST /auth/demo/fresh + «Демо: войти как Андрей» on the welcome screen: every sign-in gets a fresh private copy of veteran Andrey (consent, prescription, ЕМИАС record) and lands in the chat — no codes, nobody's crisis or half-done questionnaire leaks into another viewer. - Initial screening switch: SCREENING_ENABLED env default + /demo/settings + a toggle in the demo console; off → after consent straight to the chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
"""Thin synchronous HTTP client for the demo console (frozen API contract, `app/api/schemas.py`).
|
||
|
||
The console never imports backend code: it talks to `API_BASE_URL` only. Responses are kept as plain dicts —
|
||
the console reads a handful of fields and must survive a backend that still serves fixtures (#12).
|
||
Every failure is raised as `ApiError` with a short Russian message, so pages can show it instead of crashing.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any, Protocol
|
||
|
||
import httpx
|
||
|
||
DEFAULT_DOCTOR = "doc-smirnova"
|
||
DEFAULT_TOKEN = "tok-andrey"
|
||
|
||
|
||
def api_base_url() -> str:
|
||
return os.environ.get("API_BASE_URL", "http://localhost:8000").rstrip("/")
|
||
|
||
|
||
def doctor_id() -> str:
|
||
return os.environ.get("DEMO_DOCTOR_ID", DEFAULT_DOCTOR)
|
||
|
||
|
||
def fallback_token() -> str:
|
||
return os.environ.get("DEMO_PERSONA_TOKEN", DEFAULT_TOKEN)
|
||
|
||
|
||
class ApiError(RuntimeError):
|
||
"""Any API failure (network, HTTP status, bad JSON) with a message fit for the presenter."""
|
||
|
||
|
||
class DemoApi(Protocol):
|
||
"""What the playback runner needs; `ConsoleApi` implements it, tests use a fake."""
|
||
|
||
def clock(self) -> dict[str, Any]: ...
|
||
|
||
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: ...
|
||
|
||
def send_event(
|
||
self,
|
||
token: str,
|
||
type_: str,
|
||
*,
|
||
text: str | None = None,
|
||
button: str | None = None,
|
||
params: dict[str, Any] | None = None,
|
||
) -> list[dict[str, Any]]: ...
|
||
|
||
def outbox(self, token: str) -> list[dict[str, Any]]: ...
|
||
|
||
def patients(self, doctor: str) -> list[dict[str, Any]]: ...
|
||
|
||
|
||
class ConsoleApi:
|
||
"""Client for `/api/v1`. Short timeouts everywhere except clock moves, which replay the scheduler."""
|
||
|
||
def __init__(self, base_url: str | None = None, *, timeout: float = 5.0, client: httpx.Client | None = None):
|
||
self.base_url = (base_url or api_base_url()).rstrip("/")
|
||
self._timeout = timeout
|
||
self._client = client or httpx.Client(timeout=timeout)
|
||
|
||
# ------------------------------------------------------------------ plumbing
|
||
def _request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any:
|
||
url = f"{self.base_url}{path}"
|
||
try:
|
||
resp = self._client.request(method, url, timeout=timeout or self._timeout, **kwargs)
|
||
except httpx.TimeoutException as exc:
|
||
raise ApiError(f"{method} {path}: API не ответил вовремя") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise ApiError(f"{method} {path}: API недоступен ({type(exc).__name__})") from exc
|
||
if resp.status_code >= 400:
|
||
raise ApiError(f"{method} {path}: HTTP {resp.status_code}")
|
||
try:
|
||
return resp.json()
|
||
except ValueError as exc:
|
||
raise ApiError(f"{method} {path}: ответ не JSON") from exc
|
||
|
||
def close(self) -> None:
|
||
self._client.close()
|
||
|
||
# ------------------------------------------------------------------ service
|
||
def health(self) -> bool:
|
||
try:
|
||
return self._request("GET", "/health", timeout=2).get("status") == "ok"
|
||
except (ApiError, AttributeError):
|
||
return False
|
||
|
||
# ------------------------------------------------------------------ demo controls (ADR-005)
|
||
def personas(self) -> list[dict[str, Any]]:
|
||
return list(self._request("GET", "/api/v1/demo/personas"))
|
||
|
||
def clock(self) -> dict[str, Any]:
|
||
return dict(self._request("GET", "/api/v1/demo/clock"))
|
||
|
||
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]:
|
||
# The backend replays the scheduler up to the new time inside this call: allow it time.
|
||
body = {"days": days, "hours": hours}
|
||
return dict(self._request("POST", "/api/v1/demo/clock/advance", json=body, timeout=60))
|
||
|
||
def reset(self) -> dict[str, Any]:
|
||
return dict(self._request("POST", "/api/v1/demo/reset", timeout=60))
|
||
|
||
def demo_settings(self) -> dict[str, Any]:
|
||
return dict(self._request("GET", "/api/v1/demo/settings"))
|
||
|
||
def set_screening(self, enabled: bool) -> dict[str, Any]:
|
||
return dict(self._request("POST", "/api/v1/demo/settings", json={"screening_enabled": enabled}))
|
||
|
||
# ------------------------------------------------------------------ patient dialog
|
||
def send_event(
|
||
self,
|
||
token: str,
|
||
type_: str,
|
||
*,
|
||
text: str | None = None,
|
||
button: str | None = None,
|
||
params: dict[str, Any] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
body: dict[str, Any] = {"type": type_, "params": params or {}}
|
||
if text is not None:
|
||
body["text"] = text
|
||
if button is not None:
|
||
body["button"] = button
|
||
data = self._request("POST", f"/api/v1/dialog/{token}/events", json=body, timeout=30)
|
||
return list(data.get("replies", []))
|
||
|
||
def outbox(self, token: str) -> list[dict[str, Any]]:
|
||
return list(self._request("GET", f"/api/v1/dialog/{token}/outbox").get("replies", []))
|
||
|
||
def history(self, token: str, limit: int = 100) -> list[dict[str, Any]]:
|
||
return list(self._request("GET", f"/api/v1/dialog/{token}/history", params={"limit": limit}))
|
||
|
||
# ------------------------------------------------------------------ doctor side
|
||
def patients(self, doctor: str) -> list[dict[str, Any]]:
|
||
return list(self._request("GET", f"/api/v1/doctors/{doctor}/patients"))
|
||
|
||
def alerts(self, doctor: str, since_id: int = -1, open_only: bool = False) -> list[dict[str, Any]]:
|
||
params = {"since_id": since_id, "open_only": str(open_only).lower()}
|
||
return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params))
|
||
|
||
def connect(self, body: dict[str, Any]) -> dict[str, Any]:
|
||
"""`POST /patients/connect` (ConnectPatientIn) → {token, code, deep_link}."""
|
||
return dict(self._request("POST", "/api/v1/patients/connect", json=body, timeout=30))
|
||
|
||
def ack(self, alert_id: int, doctor: str) -> dict[str, Any]:
|
||
return dict(self._request("POST", f"/api/v1/alerts/{alert_id}/ack", json={"doctor_id": doctor}))
|
||
|
||
|
||
def personas_or_fallback(api: ConsoleApi) -> tuple[list[dict[str, Any]], str | None]:
|
||
"""Demo personas for the selector, degrading step by step so the console always has someone to talk as.
|
||
|
||
1. `GET /demo/personas` (patients with a scenario file);
|
||
2. the demo doctor's patient list (real tokens; no scenario file yet, e.g. before #18 lands);
|
||
3. a single persona with the token from `DEMO_PERSONA_TOKEN` (fixtures use `tok-andrey`).
|
||
Returns the personas and a note for the presenter when a fallback was used.
|
||
"""
|
||
try:
|
||
personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")]
|
||
if personas:
|
||
return personas, None
|
||
error = "API не вернул персон со сценарием"
|
||
except ApiError as exc:
|
||
error = str(exc)
|
||
try:
|
||
patients = [p for p in api.patients(doctor_id()) if isinstance(p, dict) and p.get("token")]
|
||
except ApiError:
|
||
patients = []
|
||
if patients:
|
||
# The demo hero first: the scenario file is guessed from the name later (`default_scenario_path`).
|
||
patients.sort(key=lambda p: not str(p.get("display_name", "")).startswith("Андрей"))
|
||
personas = [
|
||
{
|
||
"token": p["token"],
|
||
"display_name": p.get("display_name") or p["token"],
|
||
"therapy_day": p.get("therapy_day", 0),
|
||
"scenario": None,
|
||
}
|
||
for p in patients
|
||
]
|
||
return personas, f"{error}; показаны пациенты врача"
|
||
token = fallback_token()
|
||
fallback = {
|
||
"token": token,
|
||
"display_name": "Андрей С." if token == DEFAULT_TOKEN else token,
|
||
"therapy_day": 0,
|
||
"scenario": None,
|
||
}
|
||
return [fallback], f"{error}; используется токен из окружения"
|