This commit is contained in:
2026-05-08 22:35:18 +02:00
parent 3ca2fea861
commit 520d3924af
10699 changed files with 2956416 additions and 670 deletions

View File

@@ -2158,6 +2158,82 @@ def _presence_snapshot_for_user(pid: str, uid: str) -> dict:
}
def _presence_iter_practice(pid: str) -> list[tuple[str, dict]]:
"""Liefert (user_id, Roh-Eintrag) fuer alle Keys practice_id|user_id."""
pid = (pid or "").strip()
rows: list[tuple[str, dict]] = []
if not pid:
return rows
for k, rec in _PRACTICE_USER_PRESENCE.items():
if not isinstance(rec, dict):
continue
parts = str(k).split("|", 1)
if len(parts) == 2 and parts[0] == pid:
rows.append((parts[1], rec))
rows.sort(key=lambda x: x[0])
return rows
def _presence_count_for_practice(pid: str) -> int:
return len(_presence_iter_practice(pid))
def _presence_debug_any_device_recent(devs: list, within_sec: int = 120) -> bool:
"""Vergleicht device last_active grob mit TTL-Logik (wie Frontend <120s)."""
if not devs:
return False
now = time.time()
for d in devs:
if not isinstance(d, dict):
continue
la = d.get("last_active")
if la is None or la == "":
continue
try:
s = str(la).strip().replace(" ", "T")
if s.endswith("Z"):
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(s)
ts = dt.timestamp()
if now - ts < float(within_sec):
return True
except Exception:
continue
return False
def _presence_debug_resolve_practice_auth(request: Request) -> str:
"""practice_id fuer Debug: Session oder validiertes X-API-Token + X-Practice-Id."""
s = _session_from_request(request)
if s:
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(
status_code=400,
detail="practice_id in Session fehlt",
)
return pid
api_raw = (request.headers.get("X-API-Token") or "").strip()
if not api_raw:
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
try:
from aza_security import get_required_api_tokens
allowed = get_required_api_tokens()
except RuntimeError:
raise HTTPException(status_code=503, detail="API token nicht konfiguriert")
if not any(hmac.compare_digest(api_raw, t) for t in allowed):
raise HTTPException(status_code=401, detail="Unauthorized")
pid = request.headers.get("X-Practice-Id", "").strip()
if not pid:
raise HTTPException(
status_code=400,
detail="X-Practice-Id Header erforderlich",
)
return pid
def _norm_name(s: str) -> str:
"""Vergleichts-String fuer Namen: lower, trim, Akzente ueber NFKD entfernen."""
t = (s or "").strip().lower()
@@ -2759,12 +2835,15 @@ async def empfang_presence_ping(request: Request):
uid = (s.get("user_id") or "").strip()
if pid and uid:
_presence_record_ping(pid, uid, "web")
cnt = _presence_count_for_practice(pid)
return JSONResponse(
content={
"success": True,
"server_time": now,
"ttl_seconds": ttl,
"own_user_id": uid,
"practice_id": pid,
"presence_count_for_practice": cnt,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@@ -2772,18 +2851,76 @@ async def empfang_presence_ping(request: Request):
if api_raw:
pid, uid = _require_shell_api_identity(request)
_presence_record_ping(pid, uid, "desktop")
cnt = _presence_count_for_practice(pid)
return JSONResponse(
content={
"success": True,
"server_time": now,
"ttl_seconds": ttl,
"own_user_id": uid,
"practice_id": pid,
"presence_count_for_practice": cnt,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
raise HTTPException(status_code=401, detail="Nicht angemeldet")
@router.get("/presence/debug")
async def empfang_presence_debug(request: Request):
"""Diagnose: RAM-Presence + Projektion wie /users (nur Auth, keine Patientendaten)."""
pid = _presence_debug_resolve_practice_auth(request)
ttl = int(EMPFANG_PRESENCE_TTL_SECONDS)
now_ts = time.time()
now_iso = _presence_iso_utc(now_ts)
accounts = _accounts_by_practice(pid)
presence_store: list[dict] = []
for uid_store, rec in _presence_iter_practice(pid):
last = float(rec.get("last_seen", 0))
age_s = max(0, int(now_ts - last))
online = age_s <= ttl
acc = accounts.get(uid_store, {})
presence_store.append({
"user_id": uid_store,
"display_name": str(acc.get("display_name") or ""),
"role": str(acc.get("role") or ""),
"last_seen": _presence_iso_utc(last) if last > 0 else "",
"age_seconds": age_s,
"online": online,
"source": str(rec.get("source") or ""),
})
users = _practice_users(pid)
_attach_devices_to_practice_users(users, pid)
_attach_presence_to_practice_users(users, pid)
users_projection: list[dict] = []
for u in users:
devs = u.get("devices") or []
users_projection.append({
"user_id": str(u.get("user_id") or ""),
"display_name": str(u.get("display_name") or ""),
"role": str(u.get("role") or ""),
"presence_online": bool(u.get("presence_online")),
"presence_age_seconds": u.get("presence_age_seconds"),
"presence_source": str(u.get("presence_source") or ""),
"has_devices": len(devs) > 0,
"device_recent": _presence_debug_any_device_recent(devs),
})
return JSONResponse(
content={
"success": True,
"practice_id": pid,
"now": now_iso,
"ttl_seconds": ttl,
"presence_store": presence_store,
"users_projection": users_projection,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
# =====================================================================
# DM v2 — Direct-Only, Fail-Closed
# Eigene, isolierte API ausschliesslich fuer Personenchats:
@@ -3070,6 +3207,8 @@ async def empfang_tasks_create(request: Request):
meta_opt = (body.get("source_meta") or "").strip()
peer_opt = (body.get("source_peer") or "").strip()
stid_opt = (body.get("source_thread_id") or "").strip()
raw_kind = str(body.get("item_kind") or body.get("kind") or "task").strip().lower()
item_kind = "letter" if raw_kind == "letter" else "task"
task = {
"task_id": uuid.uuid4().hex[:12],
"practice_id": pid,
@@ -3083,6 +3222,7 @@ async def empfang_tasks_create(request: Request):
"created_by": (s or {}).get("display_name", "") if s else "",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"source_msg_id": (body.get("source_msg_id") or "").strip(),
"item_kind": item_kind,
}
tasks = _load_tasks()
tasks.insert(0, task)
@@ -3472,6 +3612,11 @@ _SHELL_TTL_DEFAULT = 300
_SHELL_PURPOSE = "send_to_reception_shell"
_shell_store: dict[str, dict] = {}
# Kurzlebiger Desktop-Kontext fuer WebView (Therapie/Procedere, RAM, kein Persist).
_DESKTOP_SHELL_CONTEXT_TTL_SECONDS = 900.0
_desktop_shell_latest_context_by_user: dict[tuple[str, str], dict] = {}
def _shell_cleanup_expired() -> None:
now = time.time()
@@ -3486,6 +3631,19 @@ def _shell_cleanup_expired() -> None:
pass
def _desktop_shell_context_cleanup() -> None:
now = time.time()
stale = [
k for k, rec in _desktop_shell_latest_context_by_user.items()
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
]
for k in stale:
try:
del _desktop_shell_latest_context_by_user[k]
except KeyError:
pass
def _require_shell_api_identity(request: Request) -> Tuple[str, str]:
"""Validiert X-API-Token wie aza_security; liefert (practice_id, desktop_user_id)."""
api_raw = (request.headers.get("X-API-Token") or "").strip()
@@ -3642,6 +3800,110 @@ async def empfang_shell_session_create(request: Request):
})
@router.post("/shell/context")
async def empfang_shell_context_upload(request: Request):
"""Desktop: Therapie/Procedere-Vorschau fuer WebView-Huelle (RAM, TTL, keine URL).
Keine medizinischen Inhalte loggen."""
_desktop_shell_context_cleanup()
pid, uid = _require_shell_api_identity(request)
body: dict
try:
body_raw = await request.json()
body = body_raw if isinstance(body_raw, dict) else {}
except Exception:
body = {}
therapy_raw = body.get("therapy_text")
proc_raw = body.get("procedure_text")
therapy_text = (therapy_raw if isinstance(therapy_raw, str) else str(therapy_raw or ""))[:120000]
procedure_text = (proc_raw if isinstance(proc_raw, str) else str(proc_raw or ""))[:120000]
therapy_autocopy = body.get("therapy_autocopy") in (True, "true", "1", 1)
procedure_autocopy = body.get("procedure_autocopy") in (True, "true", "1", 1)
now = time.time()
expires_at = now + float(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS)
ctx_id = "ctx_" + secrets.token_urlsafe(24)
record = {
"context_id": ctx_id,
"practice_id": pid,
"user_id": uid,
"therapy_text": therapy_text,
"procedure_text": procedure_text,
"therapy_autocopy": therapy_autocopy,
"procedure_autocopy": procedure_autocopy,
"expires_at": expires_at,
"updated_at": now,
}
_desktop_shell_latest_context_by_user[(pid, uid)] = record
_log.info(
"DESKTOP_SHELL_CONTEXT_PUT practice=%s user=%s len_ctx_id=%s nonempty=%s/%s",
pid,
uid,
len(ctx_id),
int(bool(therapy_text.strip())),
int(bool(procedure_text.strip())),
)
return JSONResponse(
content={
"success": True,
"context_id": ctx_id,
"ttl_seconds": int(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS),
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@router.get("/shell/context/me")
async def empfang_shell_context_me(request: Request):
"""Shell-WebView: eigener Kontext fuer angemeldete Session (Practice/User-Match).
Nur Metadaten im Server-Log."""
_desktop_shell_context_cleanup()
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
rec = _desktop_shell_latest_context_by_user.get((pid, uid))
now = time.time()
if not rec or float(rec.get("expires_at", 0)) < now:
return JSONResponse(
content={
"success": True,
"context_id": "",
"therapy_text": "",
"procedure_text": "",
"therapy_autocopy": False,
"procedure_autocopy": False,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
cid = str(rec.get("context_id") or "")
_log.info(
"DESKTOP_SHELL_CONTEXT_GET practice=%s user=%s len_ctx_id=%s",
pid,
uid,
len(cid),
)
return JSONResponse(
content={
"success": True,
"context_id": cid,
"therapy_text": str(rec.get("therapy_text") or ""),
"procedure_text": str(rec.get("procedure_text") or ""),
"therapy_autocopy": bool(rec.get("therapy_autocopy")),
"procedure_autocopy": bool(rec.get("procedure_autocopy")),
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@router.post("/shell/consume")
async def empfang_shell_consume(request: Request):
"""Tauscht shell_token gegen aza_session (HttpOnly Cookie). Einmal-Verbrauch."""