# -*- coding: utf-8 -*- """ AZA Empfang - Backend-Routen V5: Admin, Devices, Federation, Channels. Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben, Geraeteverwaltung, Kanaele, Praxis-Federation. Alle Daten practice-scoped. Backend ist die einzige Wahrheit. """ import hashlib import hmac import json import os import re import secrets import time import uuid from pathlib import Path from typing import Optional from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field router = APIRouter() _DATA_DIR = Path(__file__).resolve().parent / "data" _EMPFANG_FILE = _DATA_DIR / "empfang_nachrichten.json" _PRACTICES_FILE = _DATA_DIR / "empfang_practices.json" _ACCOUNTS_FILE = _DATA_DIR / "empfang_accounts.json" _SESSIONS_FILE = _DATA_DIR / "empfang_sessions.json" _TASKS_FILE = _DATA_DIR / "empfang_tasks.json" _DEVICES_FILE = _DATA_DIR / "empfang_devices.json" _CHANNELS_FILE = _DATA_DIR / "empfang_channels.json" _CONNECTIONS_FILE = _DATA_DIR / "empfang_connections.json" DEFAULT_PRACTICE_ID = "default" SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage def _ensure_data_dir(): _DATA_DIR.mkdir(parents=True, exist_ok=True) # ===================================================================== # JSON helpers (atomic write) # ===================================================================== def _load_json(path: Path, default=None): if not path.is_file(): return default if default is not None else [] try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: return default if default is not None else [] def _save_json(path: Path, data): _ensure_data_dir() tmp = str(path) + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) os.replace(tmp, str(path)) # ===================================================================== # Password hashing (PBKDF2 – no external dependency) # ===================================================================== def _hash_password(password: str, salt: str = None) -> tuple[str, str]: salt = salt or secrets.token_hex(16) dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000) return dk.hex(), salt def _verify_password(password: str, stored_hash: str, salt: str) -> bool: dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000) return hmac.compare_digest(dk.hex(), stored_hash) # ===================================================================== # Practices # ===================================================================== def _load_practices() -> dict: return _load_json(_PRACTICES_FILE, {}) def _save_practices(data: dict): _save_json(_PRACTICES_FILE, data) def _ensure_default_practice(): practices = _load_practices() if DEFAULT_PRACTICE_ID not in practices: practices[DEFAULT_PRACTICE_ID] = { "practice_id": DEFAULT_PRACTICE_ID, "name": "Meine Praxis", "invite_code": secrets.token_urlsafe(8), "created": time.strftime("%Y-%m-%d %H:%M:%S"), } _save_practices(practices) _migrate_old_users(DEFAULT_PRACTICE_ID) return practices[DEFAULT_PRACTICE_ID] def _migrate_old_users(practice_id: str): """Migriert alte empfang_users.json Strings zu echten Accounts.""" old_file = _DATA_DIR / "empfang_users.json" if not old_file.is_file(): return try: names = json.loads(old_file.read_text(encoding="utf-8")) if not isinstance(names, list): return accounts = _load_accounts() for name in names: name = name.strip() if not name: continue exists = any( a["display_name"] == name and a["practice_id"] == practice_id for a in accounts.values() ) if not exists: uid = uuid.uuid4().hex[:12] pw_hash, pw_salt = _hash_password(name.lower()) accounts[uid] = { "user_id": uid, "practice_id": practice_id, "display_name": name, "role": "mpa", "pw_hash": pw_hash, "pw_salt": pw_salt, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "status": "active", "last_login": "", "email": "", } _save_accounts(accounts) except Exception: pass # ===================================================================== # Accounts (practice-scoped users with auth) # ===================================================================== def _load_accounts() -> dict: return _load_json(_ACCOUNTS_FILE, {}) def _save_accounts(data: dict): _save_json(_ACCOUNTS_FILE, data) def _practice_users(practice_id: str) -> list[dict]: accounts = _load_accounts() return [ {"user_id": a["user_id"], "display_name": a["display_name"], "role": a["role"]} for a in accounts.values() if a.get("practice_id") == practice_id ] # ===================================================================== # Sessions (mit device_id) # ===================================================================== def _load_sessions() -> dict: return _load_json(_SESSIONS_FILE, {}) def _save_sessions(data: dict): _save_json(_SESSIONS_FILE, data) def _create_session(user_id: str, practice_id: str, display_name: str, role: str, device_id: str = None, user_agent: str = "", ip_addr: str = "") -> str: token = secrets.token_urlsafe(32) sessions = _load_sessions() sessions[token] = { "user_id": user_id, "practice_id": practice_id, "display_name": display_name, "role": role, "device_id": device_id or "", "created": time.time(), "last_active": time.time(), } _save_sessions(sessions) if device_id: _register_or_update_device( device_id=device_id, user_id=user_id, practice_id=practice_id, user_agent=user_agent, ip_addr=ip_addr, ) return token def _get_session(token: str) -> Optional[dict]: if not token: return None sessions = _load_sessions() s = sessions.get(token) if not s: return None if time.time() - s.get("created", 0) > SESSION_MAX_AGE: del sessions[token] _save_sessions(sessions) return None s["last_active"] = time.time() sessions[token] = s _save_sessions(sessions) return s def _delete_session(token: str): sessions = _load_sessions() if token in sessions: del sessions[token] _save_sessions(sessions) def _session_from_request(request: Request) -> Optional[dict]: token = request.cookies.get("aza_session") or "" if not token: auth = request.headers.get("Authorization", "") if auth.startswith("Bearer "): token = auth[7:] if not token: token = request.query_params.get("session_token", "") return _get_session(token) def _require_session(request: Request) -> dict: s = _session_from_request(request) if not s: raise HTTPException(status_code=401, detail="Nicht angemeldet") return s # ===================================================================== # Devices (Geraeteverwaltung) # ===================================================================== def _load_devices() -> dict: return _load_json(_DEVICES_FILE, {}) def _save_devices(data: dict): _save_json(_DEVICES_FILE, data) def _parse_device_info(user_agent: str) -> dict: """Einfache Heuristik zum Erkennen von Plattform, Geraetetyp und Name.""" ua = user_agent.lower() if "iphone" in ua: platform, device_type = "iOS", "mobile" elif "ipad" in ua: platform, device_type = "iOS", "tablet" elif "android" in ua: if "mobile" in ua: platform, device_type = "Android", "mobile" else: platform, device_type = "Android", "tablet" elif "macintosh" in ua or "mac os" in ua: platform, device_type = "macOS", "browser" elif "windows" in ua: platform, device_type = "Windows", "browser" elif "linux" in ua: platform, device_type = "Linux", "browser" else: platform, device_type = "Unbekannt", "browser" if "electron" in ua or "cursor" in ua: device_type = "desktop" browser = "Browser" if "edg/" in ua: browser = "Edge" elif "chrome" in ua and "chromium" not in ua: browser = "Chrome" elif "firefox" in ua: browser = "Firefox" elif "safari" in ua and "chrome" not in ua: browser = "Safari" device_name = f"{browser} auf {platform}" if device_type == "desktop": device_name = f"Desktop-App auf {platform}" elif device_type in ("mobile", "tablet"): device_name = f"{platform} {device_type.capitalize()}" return { "device_name": device_name, "platform": platform, "device_type": device_type, } def _make_device_id(user_id: str, user_agent: str) -> str: raw = f"{user_id}:{user_agent}" return hashlib.sha256(raw.encode()).hexdigest()[:12] def _register_or_update_device(device_id: str, user_id: str, practice_id: str, user_agent: str, ip_addr: str): devices = _load_devices() now = time.strftime("%Y-%m-%d %H:%M:%S") info = _parse_device_info(user_agent) if device_id in devices: dev = devices[device_id] dev["last_active"] = now dev["ip_last"] = ip_addr dev["user_agent"] = user_agent dev["device_name"] = info["device_name"] dev["platform"] = info["platform"] dev["device_type"] = info["device_type"] else: devices[device_id] = { "device_id": device_id, "user_id": user_id, "practice_id": practice_id, "device_name": info["device_name"], "platform": info["platform"], "device_type": info["device_type"], "user_agent": user_agent, "first_seen": now, "last_active": now, "trust_status": "trusted", "ip_last": ip_addr, } _save_devices(devices) def _extract_client_ip(request: Request) -> str: forwarded = request.headers.get("X-Forwarded-For", "") if forwarded: return forwarded.split(",")[0].strip() if request.client: return request.client.host return "" # ===================================================================== # Messages (practice-scoped) # ===================================================================== def _load_messages() -> list[dict]: return _load_json(_EMPFANG_FILE, []) def _save_messages(messages: list[dict]): _save_json(_EMPFANG_FILE, messages) def _msg_practice(m: dict) -> str: return m.get("practice_id") or DEFAULT_PRACTICE_ID def _filter_by_practice(messages: list[dict], pid: str) -> list[dict]: return [m for m in messages if _msg_practice(m) == pid] # ===================================================================== # Tasks (practice-scoped, server-side) # ===================================================================== def _load_tasks() -> list[dict]: return _load_json(_TASKS_FILE, []) def _save_tasks(tasks: list[dict]): _save_json(_TASKS_FILE, tasks) # ===================================================================== # Channels (Kanaele, practice-scoped) # ===================================================================== def _load_channels() -> list[dict]: return _load_json(_CHANNELS_FILE, []) def _save_channels(channels: list[dict]): _save_json(_CHANNELS_FILE, channels) _DEFAULT_CHANNEL_DEFS = [ {"name": "Allgemein", "scope": "internal", "channel_type": "group", "allowed_roles": []}, {"name": "Aerzte", "scope": "internal", "channel_type": "group", "allowed_roles": ["arzt", "admin"]}, {"name": "MPA", "scope": "internal", "channel_type": "group", "allowed_roles": ["mpa", "admin"]}, {"name": "Empfang", "scope": "internal", "channel_type": "group", "allowed_roles": ["empfang", "admin"]}, ] def _ensure_default_channels(practice_id: str): channels = _load_channels() practice_channels = [c for c in channels if c.get("practice_id") == practice_id] if practice_channels: return now = time.strftime("%Y-%m-%d %H:%M:%S") for defn in _DEFAULT_CHANNEL_DEFS: channels.append({ "channel_id": uuid.uuid4().hex[:12], "practice_id": practice_id, "name": defn["name"], "scope": defn["scope"], "channel_type": defn["channel_type"], "allowed_roles": defn["allowed_roles"], "connection_id": "", "created": now, "created_by": "", }) _save_channels(channels) # ===================================================================== # Connections / Federation (Praxis-zu-Praxis) # ===================================================================== def _load_connections() -> list[dict]: return _load_json(_CONNECTIONS_FILE, []) def _save_connections(conns: list[dict]): _save_json(_CONNECTIONS_FILE, conns) # ===================================================================== # AUTH ENDPOINTS # ===================================================================== @router.post("/auth/setup") async def auth_setup(request: Request): """Erstellt den ersten Admin-Benutzer fuer die Default-Praxis. Nur aufrufbar wenn noch keine Accounts existieren.""" practice = _ensure_default_practice() accounts = _load_accounts() practice_accounts = [a for a in accounts.values() if a.get("practice_id") == DEFAULT_PRACTICE_ID] if practice_accounts: raise HTTPException(status_code=409, detail="Setup bereits abgeschlossen. Bitte Login verwenden.") try: body = await request.json() except Exception: body = {} name = (body.get("name") or "").strip() password = (body.get("password") or "").strip() practice_name = (body.get("practice_name") or "").strip() admin_email = (body.get("email") or "").strip() if not name or not password or len(password) < 4: raise HTTPException(status_code=400, detail="Name und Passwort (min. 4 Zeichen) erforderlich") if practice_name: practices = _load_practices() practices[DEFAULT_PRACTICE_ID]["name"] = practice_name if admin_email: practices[DEFAULT_PRACTICE_ID]["admin_email"] = admin_email _save_practices(practices) practice = practices[DEFAULT_PRACTICE_ID] uid = uuid.uuid4().hex[:12] pw_hash, pw_salt = _hash_password(password) now = time.strftime("%Y-%m-%d %H:%M:%S") accounts[uid] = { "user_id": uid, "practice_id": DEFAULT_PRACTICE_ID, "display_name": name, "email": admin_email, "role": "admin", "pw_hash": pw_hash, "pw_salt": pw_salt, "created": now, "status": "active", "last_login": now, } _save_accounts(accounts) _ensure_default_channels(DEFAULT_PRACTICE_ID) ua = request.headers.get("User-Agent", "") ip = _extract_client_ip(request) dev_id = _make_device_id(uid, ua) token = _create_session(uid, DEFAULT_PRACTICE_ID, name, "admin", device_id=dev_id, user_agent=ua, ip_addr=ip) resp = JSONResponse(content={ "success": True, "user_id": uid, "role": "admin", "display_name": name, "practice_id": DEFAULT_PRACTICE_ID, "practice_name": practice.get("name", ""), "invite_code": practice.get("invite_code", ""), }) resp.set_cookie("aza_session", token, httponly=True, samesite="lax", max_age=SESSION_MAX_AGE) return resp @router.post("/auth/login") async def auth_login(request: Request): """Login mit Name oder E-Mail + Passwort.""" try: body = await request.json() except Exception: body = {} name = (body.get("name") or "").strip() password = (body.get("password") or "").strip() pid = (body.get("practice_id") or "").strip() or DEFAULT_PRACTICE_ID if not name or not password: raise HTTPException(status_code=400, detail="Name/E-Mail und Passwort erforderlich") accounts = _load_accounts() name_lower = name.lower() target = None for a in accounts.values(): if a.get("practice_id") != pid: continue if a["display_name"] == name: target = a break if (a.get("email") or "").strip().lower() == name_lower and name_lower: target = a break if not target: raise HTTPException(status_code=401, detail="Benutzer nicht gefunden") if target.get("status") == "deactivated": raise HTTPException(status_code=403, detail="Konto deaktiviert. Bitte Administrator kontaktieren.") if not _verify_password(password, target["pw_hash"], target["pw_salt"]): raise HTTPException(status_code=401, detail="Falsches Passwort") now = time.strftime("%Y-%m-%d %H:%M:%S") target["last_login"] = now _save_accounts(accounts) ua = request.headers.get("User-Agent", "") ip = _extract_client_ip(request) dev_id = body.get("device_id") or _make_device_id(target["user_id"], ua) token = _create_session(target["user_id"], pid, name, target["role"], device_id=dev_id, user_agent=ua, ip_addr=ip) result = { "success": True, "user_id": target["user_id"], "role": target["role"], "display_name": name, "practice_id": pid, } if target.get("must_change_password"): result["must_change_password"] = True resp = JSONResponse(content=result) resp.set_cookie("aza_session", token, httponly=True, samesite="lax", max_age=SESSION_MAX_AGE) return resp @router.post("/auth/register") async def auth_register(request: Request): """Neuen Benutzer registrieren mit Einladungscode.""" try: body = await request.json() except Exception: body = {} invite_code = (body.get("invite_code") or "").strip() name = (body.get("name") or "").strip() password = (body.get("password") or "").strip() role = (body.get("role") or "mpa").strip() email = (body.get("email") or "").strip() if not invite_code or not name or not password or len(password) < 4: raise HTTPException(status_code=400, detail="Einladungscode, Name und Passwort (min. 4 Zeichen) erforderlich") if role not in ("arzt", "mpa", "empfang"): role = "mpa" practices = _load_practices() target_pid = None for pid, p in practices.items(): if p.get("invite_code") == invite_code: target_pid = pid break if not target_pid: raise HTTPException(status_code=403, detail="Ungueltiger Einladungscode") accounts = _load_accounts() exists = any(a["display_name"] == name and a.get("practice_id") == target_pid for a in accounts.values()) if exists: raise HTTPException(status_code=409, detail="Benutzername bereits vergeben") uid = uuid.uuid4().hex[:12] pw_hash, pw_salt = _hash_password(password) now = time.strftime("%Y-%m-%d %H:%M:%S") accounts[uid] = { "user_id": uid, "practice_id": target_pid, "display_name": name, "email": email, "role": role, "pw_hash": pw_hash, "pw_salt": pw_salt, "created": now, "status": "active", "last_login": now, } _save_accounts(accounts) _ensure_default_channels(target_pid) ua = request.headers.get("User-Agent", "") ip = _extract_client_ip(request) dev_id = _make_device_id(uid, ua) token = _create_session(uid, target_pid, name, role, device_id=dev_id, user_agent=ua, ip_addr=ip) resp = JSONResponse(content={ "success": True, "user_id": uid, "role": role, "display_name": name, "practice_id": target_pid, }) resp.set_cookie("aza_session", token, httponly=True, samesite="lax", max_age=SESSION_MAX_AGE) return resp @router.get("/auth/me") async def auth_me(request: Request): """Aktuelle Session pruefen. Liefert User-Daten oder 401.""" s = _session_from_request(request) if not s: return JSONResponse(status_code=401, content={"authenticated": False}) return JSONResponse(content={ "authenticated": True, "user_id": s["user_id"], "display_name": s["display_name"], "role": s["role"], "practice_id": s["practice_id"], }) @router.post("/auth/logout") async def auth_logout(request: Request): token = request.cookies.get("aza_session", "") _delete_session(token) resp = JSONResponse(content={"success": True}) resp.delete_cookie("aza_session") return resp @router.post("/auth/regenerate_invite") async def auth_regenerate_invite(request: Request): """Erzeugt einen neuen Einladungscode (nur Admin).""" s = _require_session(request) if s.get("role") != "admin": raise HTTPException(status_code=403, detail="Nur Admin darf Einladungscode erneuern") practices = _load_practices() pid = s["practice_id"] if pid in practices: practices[pid]["invite_code"] = secrets.token_urlsafe(8) _save_practices(practices) return JSONResponse(content={ "success": True, "invite_code": practices.get(pid, {}).get("invite_code", ""), }) @router.post("/auth/forgot_password") async def auth_forgot_password(request: Request): """Sendet einen Passwort-Reset-Link per E-Mail.""" try: body = await request.json() except Exception: body = {} email = (body.get("email") or "").strip().lower() if not email: raise HTTPException(status_code=400, detail="E-Mail-Adresse erforderlich") accounts = _load_accounts() target = None for a in accounts.values(): if (a.get("email") or "").strip().lower() == email: target = a break if not target: return JSONResponse(content={"success": True, "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."}) reset_token = secrets.token_urlsafe(32) resets = _load_json(_DATA_DIR / "empfang_resets.json", {}) resets[reset_token] = { "user_id": target["user_id"], "email": email, "created": time.time(), } for k in list(resets.keys()): if time.time() - resets[k].get("created", 0) > 3600: del resets[k] _save_json(_DATA_DIR / "empfang_resets.json", resets) reset_link = f"https://empfang.aza-medwork.ch/?reset_token={reset_token}" _send_reset_email(email, target["display_name"], reset_link) return JSONResponse(content={"success": True, "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."}) @router.post("/auth/reset_password") async def auth_reset_password(request: Request): """Setzt das Passwort mit einem gueltigen Reset-Token.""" try: body = await request.json() except Exception: body = {} token = (body.get("reset_token") or "").strip() new_password = (body.get("password") or "").strip() if not token or not new_password or len(new_password) < 4: raise HTTPException(status_code=400, detail="Reset-Token und neues Passwort (min. 4 Zeichen) erforderlich") resets = _load_json(_DATA_DIR / "empfang_resets.json", {}) entry = resets.get(token) if not entry: raise HTTPException(status_code=400, detail="Ungueltiger oder abgelaufener Reset-Link") if time.time() - entry.get("created", 0) > 3600: del resets[token] _save_json(_DATA_DIR / "empfang_resets.json", resets) raise HTTPException(status_code=400, detail="Reset-Link ist abgelaufen (max. 1 Stunde)") user_id = entry["user_id"] accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") pw_hash, pw_salt = _hash_password(new_password) accounts[user_id]["pw_hash"] = pw_hash accounts[user_id]["pw_salt"] = pw_salt accounts[user_id].pop("must_change_password", None) _save_accounts(accounts) del resets[token] _save_json(_DATA_DIR / "empfang_resets.json", resets) return JSONResponse(content={"success": True, "message": "Passwort wurde erfolgreich geaendert."}) def _send_reset_email(to_email: str, display_name: str, reset_link: str): """Sendet Passwort-Reset-E-Mail via SMTP.""" import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart host = os.environ.get("SMTP_HOST", "").strip() port_str = os.environ.get("SMTP_PORT", "587").strip() user = os.environ.get("SMTP_USER", "").strip() password = os.environ.get("SMTP_PASS", "").strip() sender = os.environ.get("SMTP_FROM", "").strip() or user if not all([host, user, password]): print(f"[RESET-MAIL] SMTP nicht konfiguriert – Reset-Link: {reset_link}") return subject = "AZA Praxis-Chat – Passwort zuruecksetzen" text = ( f"Hallo {display_name},\n\n" f"Sie haben eine Passwort-Zuruecksetzung angefordert.\n\n" f"Klicken Sie auf diesen Link, um Ihr Passwort neu zu setzen:\n" f"{reset_link}\n\n" f"Der Link ist 1 Stunde gueltig.\n\n" f"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n" f"AZA Praxis-Chat" ) html = ( f"
" f"

Passwort zuruecksetzen

" f"

Hallo {display_name},

" f"

Sie haben eine Passwort-Zuruecksetzung angefordert.

" f"

Neues Passwort setzen

" f"

Der Link ist 1 Stunde gueltig.

" f"

Falls Sie diese Anfrage nicht gestellt haben, " f"ignorieren Sie diese E-Mail.

" ) try: msg = MIMEMultipart("alternative") msg["From"] = sender msg["To"] = to_email msg["Subject"] = subject msg.attach(MIMEText(text, "plain", "utf-8")) msg.attach(MIMEText(html, "html", "utf-8")) port = int(port_str) if port == 465: with smtplib.SMTP_SSL(host, port, timeout=15) as srv: srv.login(user, password) srv.sendmail(sender, [to_email], msg.as_string()) else: with smtplib.SMTP(host, port, timeout=15) as srv: srv.ehlo() srv.starttls() srv.ehlo() srv.login(user, password) srv.sendmail(sender, [to_email], msg.as_string()) print(f"[RESET-MAIL] OK -> {to_email}") except Exception as exc: print(f"[RESET-MAIL] FEHLER: {exc}") print(f"[RESET-MAIL] Reset-Link: {reset_link}") @router.get("/auth/needs_setup") async def auth_needs_setup(): """Pruefen ob Setup noetig ist (keine Accounts vorhanden).""" _ensure_default_practice() accounts = _load_accounts() has_accounts = any(a.get("practice_id") == DEFAULT_PRACTICE_ID for a in accounts.values()) practices = _load_practices() invite_code = practices.get(DEFAULT_PRACTICE_ID, {}).get("invite_code", "") return JSONResponse(content={ "needs_setup": not has_accounts, "invite_code": invite_code if not has_accounts else "", }) # ===================================================================== # ADMIN ENDPOINTS (nur Rolle admin) # ===================================================================== def _require_admin(request: Request) -> dict: s = _require_session(request) if s.get("role") != "admin": raise HTTPException(status_code=403, detail="Admin-Berechtigung erforderlich") return s @router.get("/admin/users") async def admin_list_users(request: Request): """Alle Benutzer der Praxis mit vollen Details.""" s = _require_admin(request) pid = s["practice_id"] accounts = _load_accounts() result = [] for a in accounts.values(): if a.get("practice_id") != pid: continue result.append({ "user_id": a["user_id"], "display_name": a["display_name"], "role": a.get("role", "mpa"), "status": a.get("status", "active"), "created": a.get("created", ""), "last_login": a.get("last_login", ""), "email": a.get("email", ""), }) return JSONResponse(content={"success": True, "users": result}) @router.post("/admin/users/{user_id}/role") async def admin_change_role(user_id: str, request: Request): """Rolle eines Benutzers aendern.""" s = _require_admin(request) try: body = await request.json() except Exception: body = {} new_role = (body.get("role") or "").strip() if not new_role: raise HTTPException(status_code=400, detail="Rolle erforderlich") if new_role not in ("admin", "arzt", "mpa", "empfang"): raise HTTPException(status_code=400, detail="Ungueltige Rolle") if user_id == s["user_id"] and new_role != "admin": raise HTTPException(status_code=400, detail="Eigene Admin-Rolle kann nicht entfernt werden") accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if accounts[user_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") accounts[user_id]["role"] = new_role _save_accounts(accounts) return JSONResponse(content={"success": True, "user_id": user_id, "role": new_role}) @router.post("/admin/users/{user_id}/deactivate") async def admin_deactivate_user(user_id: str, request: Request): """Benutzer deaktivieren und alle Sessions loeschen.""" s = _require_admin(request) if user_id == s["user_id"]: raise HTTPException(status_code=400, detail="Eigenes Konto kann nicht deaktiviert werden") accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if accounts[user_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") accounts[user_id]["status"] = "deactivated" _save_accounts(accounts) sessions = _load_sessions() sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id} _save_sessions(sessions) return JSONResponse(content={"success": True, "user_id": user_id, "status": "deactivated"}) @router.post("/admin/users/{user_id}/activate") async def admin_activate_user(user_id: str, request: Request): """Benutzer reaktivieren.""" s = _require_admin(request) accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if accounts[user_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") accounts[user_id]["status"] = "active" _save_accounts(accounts) return JSONResponse(content={"success": True, "user_id": user_id, "status": "active"}) @router.delete("/admin/users/{user_id}") async def admin_delete_user(user_id: str, request: Request): """Benutzer permanent loeschen inkl. Sessions und Geraete.""" s = _require_admin(request) if user_id == s["user_id"]: raise HTTPException(status_code=400, detail="Eigenes Konto kann nicht geloescht werden") accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if accounts[user_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") del accounts[user_id] _save_accounts(accounts) sessions = _load_sessions() sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id} _save_sessions(sessions) devices = _load_devices() devices = {k: v for k, v in devices.items() if v.get("user_id") != user_id} _save_devices(devices) return JSONResponse(content={"success": True, "deleted": user_id}) @router.post("/admin/users/{user_id}/reset_password") async def admin_reset_password(user_id: str, request: Request): """Temporaeres Passwort generieren. Benutzer muss es beim naechsten Login aendern.""" s = _require_admin(request) accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if accounts[user_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") temp_pw = secrets.token_urlsafe(8) pw_hash, pw_salt = _hash_password(temp_pw) accounts[user_id]["pw_hash"] = pw_hash accounts[user_id]["pw_salt"] = pw_salt accounts[user_id]["must_change_password"] = True _save_accounts(accounts) return JSONResponse(content={ "success": True, "user_id": user_id, "temp_password": temp_pw, }) @router.get("/admin/devices") async def admin_list_devices(request: Request): """Alle Geraete aller Benutzer der Praxis.""" s = _require_admin(request) pid = s["practice_id"] devices = _load_devices() accounts = _load_accounts() user_names = {a["user_id"]: a["display_name"] for a in accounts.values()} result = [] for d in devices.values(): if d.get("practice_id") != pid: continue entry = dict(d) entry["user_name"] = user_names.get(d.get("user_id"), d.get("user_id", "")) result.append(entry) result.sort(key=lambda d: d.get("last_active", ""), reverse=True) return JSONResponse(content={"success": True, "devices": result}) @router.post("/admin/devices/{device_id}/block") async def admin_block_device(device_id: str, request: Request): """Geraet blockieren und zugehoerige Sessions loeschen.""" s = _require_admin(request) devices = _load_devices() if device_id not in devices: raise HTTPException(status_code=404, detail="Geraet nicht gefunden") dev = devices[device_id] if dev.get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Geraet gehoert zu anderer Praxis") dev["trust_status"] = "blocked" _save_devices(devices) sessions = _load_sessions() sessions = {k: v for k, v in sessions.items() if v.get("device_id") != device_id} _save_sessions(sessions) return JSONResponse(content={"success": True, "device_id": device_id, "trust_status": "blocked"}) @router.delete("/admin/devices/{device_id}") async def admin_delete_device(device_id: str, request: Request): """Geraetedatensatz loeschen.""" s = _require_admin(request) devices = _load_devices() if device_id not in devices: raise HTTPException(status_code=404, detail="Geraet nicht gefunden") if devices[device_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Geraet gehoert zu anderer Praxis") del devices[device_id] _save_devices(devices) return JSONResponse(content={"success": True, "deleted": device_id}) @router.post("/admin/users/{user_id}/logout_all") async def admin_logout_all(user_id: str, request: Request): """Alle Sessions eines Benutzers loeschen.""" s = _require_admin(request) accounts = _load_accounts() if user_id not in accounts: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if accounts[user_id].get("practice_id") != s["practice_id"]: raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") sessions = _load_sessions() removed = sum(1 for v in sessions.values() if v.get("user_id") == user_id) sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id} _save_sessions(sessions) return JSONResponse(content={"success": True, "user_id": user_id, "sessions_removed": removed}) # ===================================================================== # USER MANAGEMENT (admin only for invite/role changes) # ===================================================================== @router.get("/users") async def empfang_users(request: Request): """Liefert Benutzer der Praxis. Offen fuer alle authentifizierten + Legacy.""" s = _session_from_request(request) if s: users = _practice_users(s["practice_id"]) return JSONResponse(content={ "users": [u["display_name"] for u in users], "users_full": users, "practice_id": s["practice_id"], }) pid = request.query_params.get("practice_id", "") or DEFAULT_PRACTICE_ID users = _practice_users(pid) if users: return JSONResponse(content={ "users": [u["display_name"] for u in users], "practice_id": pid, }) old_file = _DATA_DIR / "empfang_users.json" if old_file.is_file(): try: names = json.loads(old_file.read_text(encoding="utf-8")) if isinstance(names, list): return JSONResponse(content={"users": names, "practice_id": pid}) except Exception: pass return JSONResponse(content={"users": [], "practice_id": pid}) @router.post("/users") async def empfang_register_user(request: Request): """Legacy-kompatibel: Benutzer anlegen/umbenennen/loeschen.""" try: body = await request.json() except Exception: body = {} name = (body.get("name") or "").strip() action = (body.get("action") or "add").strip() pid = DEFAULT_PRACTICE_ID s = _session_from_request(request) if s: pid = s["practice_id"] if not name: return JSONResponse(content={"success": False}) accounts = _load_accounts() if action == "delete": to_del = [uid for uid, a in accounts.items() if a["display_name"] == name and a.get("practice_id") == pid] for uid in to_del: del accounts[uid] _save_accounts(accounts) elif action == "rename": new_name = (body.get("new_name") or "").strip() if new_name: for a in accounts.values(): if a["display_name"] == name and a.get("practice_id") == pid: a["display_name"] = new_name _save_accounts(accounts) else: exists = any(a["display_name"] == name and a.get("practice_id") == pid for a in accounts.values()) if not exists: uid = uuid.uuid4().hex[:12] pw_hash, pw_salt = _hash_password(name.lower()) accounts[uid] = { "user_id": uid, "practice_id": pid, "display_name": name, "role": "mpa", "pw_hash": pw_hash, "pw_salt": pw_salt, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "status": "active", "last_login": "", "email": "", } _save_accounts(accounts) users = _practice_users(pid) return JSONResponse(content={ "success": True, "users": [u["display_name"] for u in users], "practice_id": pid, }) # ===================================================================== # MESSAGE ROUTES (practice-scoped) # ===================================================================== class EmpfangMessage(BaseModel): medikamente: str = "" therapieplan: str = "" procedere: str = "" kommentar: str = "" patient: str = "" absender: str = "" zeitstempel: str = "" practice_id: str = "" extras: dict = Field(default_factory=dict) @router.post("/send") async def empfang_send(msg: EmpfangMessage, request: Request): s = _session_from_request(request) pid = msg.practice_id.strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) absender = msg.absender.strip() if s and not absender: absender = s["display_name"] msg_id = uuid.uuid4().hex[:12] messages = _load_messages() thread_id = msg_id reply_to = (msg.extras or {}).get("reply_to", "") if reply_to: for m in messages: if m.get("id") == reply_to: thread_id = m.get("thread_id", reply_to) break else: thread_id = reply_to entry = { "id": msg_id, "thread_id": thread_id, "practice_id": pid, "medikamente": msg.medikamente.strip(), "therapieplan": msg.therapieplan.strip(), "procedere": msg.procedere.strip(), "kommentar": msg.kommentar.strip(), "patient": msg.patient.strip(), "absender": absender, "zeitstempel": msg.zeitstempel.strip() or time.strftime("%Y-%m-%d %H:%M:%S"), "empfangen": time.strftime("%Y-%m-%d %H:%M:%S"), "status": "offen", "user_id": s["user_id"] if s else "", } if msg.extras: entry["extras"] = msg.extras messages.insert(0, entry) _save_messages(messages) return JSONResponse(content={ "success": True, "id": msg_id, "thread_id": thread_id, "practice_id": pid, }) @router.get("/messages") async def empfang_list(request: Request, practice_id: Optional[str] = Query(None)): s = _session_from_request(request) pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) messages = _load_messages() filtered = _filter_by_practice(messages, pid) return JSONResponse(content={"success": True, "messages": filtered}) @router.get("/thread/{thread_id}") async def empfang_thread(thread_id: str, request: Request, practice_id: Optional[str] = Query(None)): s = _session_from_request(request) pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) messages = _load_messages() thread = [m for m in messages if m.get("thread_id") == thread_id and _msg_practice(m) == pid] thread.sort(key=lambda m: m.get("empfangen", "")) return JSONResponse(content={"success": True, "messages": thread}) @router.post("/messages/{msg_id}/done") async def empfang_done(msg_id: str): messages = _load_messages() target = next((m for m in messages if m.get("id") == msg_id), None) if not target: raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") tid = target.get("thread_id", msg_id) pid = _msg_practice(target) for m in messages: if m.get("thread_id") == tid and _msg_practice(m) == pid: m["status"] = "erledigt" _save_messages(messages) return JSONResponse(content={"success": True}) @router.delete("/messages/{msg_id}") async def empfang_delete(msg_id: str): messages = _load_messages() target = next((m for m in messages if m.get("id") == msg_id), None) if not target: raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") tid = target.get("thread_id", msg_id) pid = _msg_practice(target) if tid == msg_id: new = [m for m in messages if not (m.get("thread_id", m.get("id")) == msg_id and _msg_practice(m) == pid) and not (m.get("id") == msg_id)] else: new = [m for m in messages if m.get("id") != msg_id] _save_messages(new) return JSONResponse(content={"success": True}) # ===================================================================== # TASKS (practice-scoped, server-side) # ===================================================================== @router.get("/tasks") async def empfang_tasks_list(request: Request): s = _session_from_request(request) pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID tasks = _load_tasks() filtered = [t for t in tasks if t.get("practice_id", DEFAULT_PRACTICE_ID) == pid] return JSONResponse(content={"success": True, "tasks": filtered}) @router.post("/tasks") async def empfang_tasks_create(request: Request): s = _session_from_request(request) pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID try: body = await request.json() except Exception: body = {} text = (body.get("text") or "").strip() if not text: raise HTTPException(status_code=400, detail="Text erforderlich") task = { "task_id": uuid.uuid4().hex[:12], "practice_id": pid, "text": text, "done": False, "assignee": (body.get("assignee") or "").strip(), "created_by": s["display_name"] if s else "", "created": time.strftime("%Y-%m-%d %H:%M:%S"), } tasks = _load_tasks() tasks.insert(0, task) _save_tasks(tasks) return JSONResponse(content={"success": True, "task": task}) @router.post("/tasks/{task_id}/update") async def empfang_tasks_update(task_id: str, request: Request): try: body = await request.json() except Exception: body = {} tasks = _load_tasks() target = next((t for t in tasks if t.get("task_id") == task_id), None) if not target: raise HTTPException(status_code=404, detail="Aufgabe nicht gefunden") if "done" in body: target["done"] = bool(body["done"]) if "text" in body: target["text"] = (body["text"] or "").strip() or target["text"] if "assignee" in body: target["assignee"] = (body["assignee"] or "").strip() _save_tasks(tasks) return JSONResponse(content={"success": True, "task": target}) @router.delete("/tasks/{task_id}") async def empfang_tasks_delete(task_id: str): tasks = _load_tasks() tasks = [t for t in tasks if t.get("task_id") != task_id] _save_tasks(tasks) return JSONResponse(content={"success": True}) # ===================================================================== # CHANNEL ENDPOINTS (Kanaele) # ===================================================================== @router.get("/channels") async def channels_list(request: Request): """Kanaele anzeigen, gefiltert nach Rolle des Benutzers.""" s = _require_session(request) pid = s["practice_id"] role = s.get("role", "mpa") _ensure_default_channels(pid) channels = _load_channels() visible = [] for c in channels: if c.get("practice_id") != pid: continue allowed = c.get("allowed_roles", []) if not allowed or role in allowed: visible.append(c) return JSONResponse(content={"success": True, "channels": visible}) @router.post("/channels") async def channels_create(request: Request): """Neuen Kanal erstellen (nur Admin).""" s = _require_admin(request) try: body = await request.json() except Exception: body = {} name = (body.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="Kanalname erforderlich") scope = body.get("scope", "internal") if scope not in ("internal", "external"): scope = "internal" channel_type = body.get("channel_type", "group") if channel_type not in ("group", "direct", "external"): channel_type = "group" allowed_roles = body.get("allowed_roles", []) if not isinstance(allowed_roles, list): allowed_roles = [] channel = { "channel_id": uuid.uuid4().hex[:12], "practice_id": s["practice_id"], "name": name, "scope": scope, "channel_type": channel_type, "allowed_roles": allowed_roles, "connection_id": body.get("connection_id", ""), "created": time.strftime("%Y-%m-%d %H:%M:%S"), "created_by": s["user_id"], } channels = _load_channels() channels.append(channel) _save_channels(channels) return JSONResponse(content={"success": True, "channel": channel}) @router.post("/channels/{channel_id}/update") async def channels_update(channel_id: str, request: Request): """Kanal aktualisieren (nur Admin).""" s = _require_admin(request) try: body = await request.json() except Exception: body = {} channels = _load_channels() target = None for c in channels: if c.get("channel_id") == channel_id and c.get("practice_id") == s["practice_id"]: target = c break if not target: raise HTTPException(status_code=404, detail="Kanal nicht gefunden") if "name" in body: new_name = (body["name"] or "").strip() if new_name: target["name"] = new_name if "allowed_roles" in body: ar = body["allowed_roles"] if isinstance(ar, list): target["allowed_roles"] = ar _save_channels(channels) return JSONResponse(content={"success": True, "channel": target}) @router.delete("/channels/{channel_id}") async def channels_delete(channel_id: str, request: Request): """Kanal loeschen (nur Admin, keine Default-Kanaele).""" s = _require_admin(request) channels = _load_channels() target = None for c in channels: if c.get("channel_id") == channel_id and c.get("practice_id") == s["practice_id"]: target = c break if not target: raise HTTPException(status_code=404, detail="Kanal nicht gefunden") default_names = {d["name"] for d in _DEFAULT_CHANNEL_DEFS} if target.get("name") in default_names and target.get("scope") == "internal": raise HTTPException(status_code=400, detail="Standard-Kanaele koennen nicht geloescht werden") channels = [c for c in channels if c.get("channel_id") != channel_id] _save_channels(channels) return JSONResponse(content={"success": True, "deleted": channel_id}) # ===================================================================== # FEDERATION ENDPOINTS (Praxis-zu-Praxis-Verbindungen) # ===================================================================== @router.post("/federation/invite") async def federation_invite(request: Request): """Einladung zur Praxis-Verbindung erstellen.""" s = _require_admin(request) try: body = await request.json() except Exception: body = {} pid = s["practice_id"] practices = _load_practices() practice_name = practices.get(pid, {}).get("name", "Unbekannte Praxis") conn = { "connection_id": uuid.uuid4().hex[:12], "practice_a_id": pid, "practice_b_id": "", "status": "pending", "invite_token": secrets.token_urlsafe(24), "created_by": s["user_id"], "accepted_by": "", "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "accepted_at": "", "revoked_at": "", "practice_a_name": practice_name, "practice_b_name": "", "message": (body.get("message") or "").strip(), } conns = _load_connections() conns.append(conn) _save_connections(conns) return JSONResponse(content={ "success": True, "connection_id": conn["connection_id"], "invite_token": conn["invite_token"], }) @router.post("/federation/accept") async def federation_accept(request: Request): """Verbindungseinladung annehmen.""" s = _require_admin(request) try: body = await request.json() except Exception: body = {} invite_token = (body.get("invite_token") or "").strip() if not invite_token: raise HTTPException(status_code=400, detail="invite_token erforderlich") conns = _load_connections() target = None for c in conns: if c.get("invite_token") == invite_token and c.get("status") == "pending": target = c break if not target: raise HTTPException(status_code=404, detail="Einladung nicht gefunden oder bereits verwendet") pid_b = s["practice_id"] if target["practice_a_id"] == pid_b: raise HTTPException(status_code=400, detail="Kann eigene Einladung nicht annehmen") practices = _load_practices() practice_b_name = practices.get(pid_b, {}).get("name", "Unbekannte Praxis") now = time.strftime("%Y-%m-%d %H:%M:%S") target["practice_b_id"] = pid_b target["practice_b_name"] = practice_b_name target["status"] = "active" target["accepted_by"] = s["user_id"] target["accepted_at"] = now _save_connections(conns) channel_name = f"{target['practice_a_name']} \u2194 {practice_b_name}" conn_id = target["connection_id"] channels = _load_channels() for practice_id in (target["practice_a_id"], pid_b): channels.append({ "channel_id": uuid.uuid4().hex[:12], "practice_id": practice_id, "name": channel_name, "scope": "external", "channel_type": "external", "allowed_roles": [], "connection_id": conn_id, "created": now, "created_by": s["user_id"], }) _save_channels(channels) return JSONResponse(content={ "success": True, "connection_id": conn_id, "practice_a": target["practice_a_name"], "practice_b": practice_b_name, }) @router.get("/federation/connections") async def federation_connections(request: Request): """Alle Verbindungen der eigenen Praxis anzeigen.""" s = _require_admin(request) pid = s["practice_id"] conns = _load_connections() result = [c for c in conns if c.get("practice_a_id") == pid or c.get("practice_b_id") == pid] return JSONResponse(content={"success": True, "connections": result}) @router.post("/federation/connections/{connection_id}/revoke") async def federation_revoke(connection_id: str, request: Request): """Verbindung widerrufen / trennen.""" s = _require_admin(request) pid = s["practice_id"] conns = _load_connections() target = None for c in conns: if c.get("connection_id") == connection_id: if c.get("practice_a_id") == pid or c.get("practice_b_id") == pid: target = c break if not target: raise HTTPException(status_code=404, detail="Verbindung nicht gefunden") if target["status"] == "revoked": raise HTTPException(status_code=400, detail="Verbindung bereits widerrufen") target["status"] = "revoked" target["revoked_at"] = time.strftime("%Y-%m-%d %H:%M:%S") _save_connections(conns) return JSONResponse(content={"success": True, "connection_id": connection_id, "status": "revoked"}) @router.get("/federation/practices") async def federation_practices(request: Request): """Verbundene Praxen anzeigen (fuer alle authentifizierten Benutzer).""" s = _require_session(request) pid = s["practice_id"] conns = _load_connections() result = [] for c in conns: if c.get("status") != "active": continue if c.get("practice_a_id") == pid: result.append({ "practice_id": c.get("practice_b_id"), "practice_name": c.get("practice_b_name", ""), "connection_id": c.get("connection_id"), "status": c.get("status"), }) elif c.get("practice_b_id") == pid: result.append({ "practice_id": c.get("practice_a_id"), "practice_name": c.get("practice_a_name", ""), "connection_id": c.get("connection_id"), "status": c.get("status"), }) return JSONResponse(content={"success": True, "practices": result}) # ===================================================================== # CLEANUP + PRACTICE INFO # ===================================================================== @router.post("/cleanup") async def empfang_cleanup(request: Request): try: body = await request.json() except Exception: body = {} max_days = int(body.get("max_age_days", 30)) s = _session_from_request(request) pid = (body.get("practice_id") or "").strip() or ( s["practice_id"] if s else DEFAULT_PRACTICE_ID) cutoff = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(time.time() - max_days * 86400), ) messages = _load_messages() before = len(messages) kept = [ m for m in messages if _msg_practice(m) != pid or (m.get("empfangen") or m.get("zeitstempel", "")) >= cutoff ] removed = before - len(kept) if removed > 0: _save_messages(kept) return JSONResponse(content={ "success": True, "removed": removed, "remaining": len(kept), }) @router.get("/practice/info") async def empfang_practice_info(request: Request): s = _session_from_request(request) pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID users = _practice_users(pid) messages = _filter_by_practice(_load_messages(), pid) open_count = sum(1 for m in messages if m.get("status") == "offen") practices = _load_practices() p = practices.get(pid, {}) result = { "practice_id": pid, "practice_name": p.get("name", ""), "user_count": len(users), "message_count": len(messages), "open_count": open_count, } if s and s.get("role") == "admin": result["invite_code"] = p.get("invite_code", "") result["admin_email"] = p.get("admin_email", "") return JSONResponse(content=result) # ===================================================================== # HTML PAGE # ===================================================================== @router.get("/", response_class=HTMLResponse) async def empfang_page(request: Request): html_path = Path(__file__).resolve().parent / "web" / "empfang.html" if html_path.is_file(): return HTMLResponse( content=html_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}, ) return HTMLResponse(content="

empfang.html nicht gefunden

", status_code=404)