# -*- coding: utf-8 -*- """ Offline-Diagnose: Empfang-Praxen, Konten, Sessions, Geräte, externe Links. Nur lesend. Keine Datenänderung. Für Betrieb: auf dem Server im Projektroot oder mit --data-dir ausführen. Beispiel: python tools/diagnose_empfang_practice_inventory.py python tools/diagnose_empfang_practice_inventory.py --data-dir /pfad/zum/data Ausgabe: stdout (UTF-8). Keine vollen Lizenzschlüssel; Konten ohne pw_hash-Inhalt. """ from __future__ import annotations import argparse import hashlib import json from collections import defaultdict from pathlib import Path from typing import Any def _load(path: Path, default): if not path.is_file(): return default try: return json.loads(path.read_text(encoding="utf-8")) except Exception as exc: print(f"# FEHLER beim Lesen {path}: {exc}") return default def _short_uid(uid: str, keep: int = 10) -> str: s = (uid or "").strip() if len(s) <= keep: return s return s[:keep] + "…" def _license_tail(acc: dict) -> str: for key in ("license_key", "activation_key", "stored_license"): raw = (acc.get(key) or "").strip() if raw: return "***" + raw[-4:] if len(raw) >= 4 else "****" return "" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument( "--data-dir", type=Path, help="Ordner mit empfang_*.json (Default: ../data relativ zu Repo)", ) args = ap.parse_args() here = Path(__file__).resolve().parent repo = here.parent data_dir = args.data_dir or (repo / "data") paths = { "practices": data_dir / "empfang_practices.json", "accounts": data_dir / "empfang_accounts.json", "sessions": data_dir / "empfang_sessions.json", "devices": data_dir / "empfang_devices.json", "links": data_dir / "empfang_practice_links.json", "connections": data_dir / "empfang_connections.json", } print("=== Empfang Practice Inventory (read-only) ===") print(f"data_dir={data_dir.resolve()}") for name, p in paths.items(): print(f" {name}: {'OK' if p.is_file() else '— fehlt —'} {p.name}") practices: dict[str, Any] = _load(paths["practices"], {}) accounts_raw = _load(paths["accounts"], {}) sessions_raw = _load(paths["sessions"], {}) devices_raw = _load(paths["devices"], {}) links_raw = _load(paths["links"], {"links": []}) connections_raw = _load(paths["connections"], []) accounts: dict[str, dict] = accounts_raw if isinstance(accounts_raw, dict) else {} sessions_dict: dict = sessions_raw if isinstance(sessions_raw, dict) else {} devices_dict: dict = devices_raw if isinstance(devices_raw, dict) else {} links: list = links_raw.get("links") or [] if isinstance(links_raw, dict) else [] # Accounts by practice by_practice: dict[str, list[dict]] = defaultdict(list) for uid, a in accounts.items(): if not isinstance(a, dict): continue pid = (a.get("practice_id") or "").strip() if pid: by_practice[pid].append(a) # Session counts by practice_id (token -> session record) sess_by_practice: dict[str, int] = defaultdict(int) for _tok, s in sessions_dict.items(): if isinstance(s, dict): pid = (s.get("practice_id") or "").strip() if pid: sess_by_practice[pid] += 1 dev_by_practice: dict[str, int] = defaultdict(int) for _dk, d in devices_dict.items(): if not isinstance(d, dict): continue pid = (d.get("practice_id") or "").strip() if pid: dev_by_practice[pid] += 1 ext_count: dict[str, int] = defaultdict(int) for L in links: if not isinstance(L, dict): continue for k in ("source_practice_id", "target_practice_id"): pid = (L.get(k) or "").strip() if pid: ext_count[pid] += 1 print("\n--- Praxis-Übersicht ---") all_pids = sorted(set(practices.keys()) | set(by_practice.keys())) dup_name_hints: dict[str, list[str]] = defaultdict(list) for pid, pdata in practices.items(): if isinstance(pdata, dict): nm = (pdata.get("name") or "").strip().lower() if nm: dup_name_hints[nm].append(pid) for pid in all_pids: pdata = practices.get(pid, {}) if isinstance(practices.get(pid), dict) else {} name = (pdata.get("name") or "").strip() or "(ohne Eintrag in empfang_practices.json)" invite = (pdata.get("invite_code") or "").strip() invite_h = hashlib.sha256(invite.encode()).hexdigest()[:10] if invite else "" members = by_practice.get(pid, []) admins = [m for m in members if str(m.get("role") or "").lower() == "admin"] print(f"\n* practice_id: {pid}") print(f" practice_name: {name}") print(f" invite_code: {invite or '—'} (sha256-prefix: {invite_h or '—'})") print(f" user_count: {len(members)} admin_count: {len(admins)}") if admins: print(" admins: " + ", ".join((a.get("display_name") or "?") for a in admins[:20])) print(f" sessions_count: {sess_by_practice.get(pid, 0)}") print(f" devices_count: {dev_by_practice.get(pid, 0)}") print(f" external_link_rows_touching_practice: {ext_count.get(pid, 0)}") print(" users (gekürzt):") for m in sorted(members, key=lambda x: str(x.get("display_name") or "").lower())[:40]: dn = (m.get("display_name") or "").strip() ln = (m.get("login_name") or "").strip() role = (m.get("role") or "").strip() uid = _short_uid(str(m.get("user_id") or "")) lt = _license_tail(m) print(f" - {dn!r} login={ln!r} role={role} uid={uid} lic_hint={lt or '—'}") if len(members) > 40: print(f" … ({len(members) - 40} weitere)") print("\n--- Hinweise Dubletten (gleicher Praxisname, verschiedene IDs) ---") found = False for nm, pids in dup_name_hints.items(): if len(pids) > 1: found = True print(f" name_low={nm!r} -> {pids}") if not found: print(" keine Namens-Kollisionen in empfang_practices.json") print("\n--- Konten ohne Praxis-Eintrag in practices.json ---") orphan_accounts = [] for uid, a in accounts.items(): if not isinstance(a, dict): continue pid = (a.get("practice_id") or "").strip() if pid and pid not in practices: orphan_accounts.append((pid, a)) if not orphan_accounts: print(" keine") else: for pid, a in orphan_accounts[:50]: print(f" practice_id={pid} user={a.get('display_name')} (Praxis-Stammsatz fehlt)") print("\n--- Ende (keine Änderungen vorgenommen) ---") print( "Reparatur: nicht automatisch. STRATEGIE: siehe Nutzer-Anforderung BLOCK 5 / Arzt-Admin entscheidet nach Backup." ) return 0 if __name__ == "__main__": raise SystemExit(main())