Files
aza/AzA march 2026/empfang_routes.py
2026-04-21 10:00:36 +02:00

2203 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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, Tuple
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"
_LEGACY_DEFAULT_PID = "default"
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage
def _generate_practice_id() -> str:
return f"prac_{uuid.uuid4().hex[:12]}"
def _resolve_practice_id(request: Request) -> str:
"""Ermittelt die practice_id aus Session, Header oder Query.
Kein stiller Fallback auf eine Default-Praxis."""
s = _session_from_request(request)
if s:
return s["practice_id"]
pid = request.headers.get("X-Practice-Id", "").strip()
if pid:
return pid
pid = request.query_params.get("practice_id", "").strip()
if pid:
return pid
return ""
def _require_practice_id(request: Request) -> str:
"""Wie _resolve_practice_id, aber wirft 400 wenn keine practice_id."""
pid = _resolve_practice_id(request)
if not pid:
raise HTTPException(
status_code=400,
detail="practice_id erforderlich (X-Practice-Id Header, Session oder Query)")
return pid
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 _generate_chat_invite_code() -> str:
"""Lesbarer Chat-Einladungscode im Format CHAT-XXXX-XXXX."""
import random
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
part1 = "".join(random.choices(chars, k=4))
part2 = "".join(random.choices(chars, k=4))
return f"CHAT-{part1}-{part2}"
def _ensure_practice(practice_id: str, name: str = "Meine Praxis",
admin_email: str = "") -> dict:
"""Stellt sicher, dass eine Praxis mit dieser ID existiert."""
practices = _load_practices()
if practice_id not in practices:
practices[practice_id] = {
"practice_id": practice_id,
"name": name,
"invite_code": _generate_chat_invite_code(),
"admin_email": admin_email,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_save_practices(practices)
return practices[practice_id]
def _migrate_legacy_to_practice(new_pid: str):
"""Migriert alle Daten von der alten 'default'-Praxis zur neuen practice_id.
Wird einmalig beim ersten Provisioning aufgerufen."""
accounts = _load_accounts()
migrated = False
for a in accounts.values():
if a.get("practice_id") == _LEGACY_DEFAULT_PID:
a["practice_id"] = new_pid
migrated = True
if migrated:
_save_accounts(accounts)
devices = _load_devices()
for d in devices.values():
if d.get("practice_id") == _LEGACY_DEFAULT_PID:
d["practice_id"] = new_pid
_save_devices(devices)
sessions = _load_sessions()
for s in sessions.values():
if s.get("practice_id") == _LEGACY_DEFAULT_PID:
s["practice_id"] = new_pid
_save_sessions(sessions)
messages = _load_messages()
for m in messages:
if m.get("practice_id", _LEGACY_DEFAULT_PID) == _LEGACY_DEFAULT_PID:
m["practice_id"] = new_pid
_save_messages(messages)
practices = _load_practices()
if _LEGACY_DEFAULT_PID in practices:
old = practices.pop(_LEGACY_DEFAULT_PID)
if new_pid not in practices:
old["practice_id"] = new_pid
practices[new_pid] = old
_save_practices(practices)
try:
channels = _load_channels()
for c in channels:
if c.get("practice_id") == _LEGACY_DEFAULT_PID:
c["practice_id"] = new_pid
_save_channels(channels)
except Exception:
pass
_migrate_old_users(new_pid)
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
]
# =====================================================================
# Login / Passwort-Reset Hilfen (Mandant, Mehrfach-E-Mail)
# =====================================================================
def _is_likely_email(s: str) -> bool:
"""Grobe Erkennung E-Mail vs. Benutzername (Anzeigename)."""
s = (s or "").strip()
if "@" not in s or len(s) < 5:
return False
parts = s.split("@", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
return False
return "." in parts[1]
def _norm_email(e: str) -> str:
return (e or "").strip().lower()
def _practice_id_from_client(request: Request, body: dict) -> str:
"""practice_id fuer Login/Forgot ohne Session: Body, Header, Query."""
pid = (body.get("practice_id") or "").strip()
if pid:
return pid
pid = request.headers.get("X-Practice-Id", "").strip()
if pid:
return pid
return request.query_params.get("practice_id", "").strip()
def _practice_label(practices: dict, pid: str) -> str:
p = practices.get(pid) or {}
return (p.get("name") or "").strip() or pid
def _send_reset_for_account(acc: dict) -> dict:
"""Token erstellen, Mail senden — Inhalt fuer JSONResponse(content=...)."""
email_to = (acc.get("email") or "").strip()
if not email_to:
return {
"success": False,
"step": "no_email",
"message": (
"Für dieses Konto ist keine E-Mail-Adresse hinterlegt. Bitte lassen Sie das "
"Passwort von einer Administratorin oder einem Administrator der Praxis "
"zurücksetzen."
),
}
reset_token = secrets.token_urlsafe(32)
resets = _load_json(_DATA_DIR / "empfang_resets.json", {})
resets[reset_token] = {
"user_id": acc["user_id"],
"email": _norm_email(email_to),
"display_name": (acc.get("display_name") or "").strip(),
"practice_id": (acc.get("practice_id") or "").strip(),
"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)
_web_base = os.environ.get(
"EMPFANG_WEB_BASE", "https://empfang.aza-medwork.ch/empfang"
).rstrip("/")
reset_link = f"{_web_base}/?reset_token={reset_token}"
_send_reset_email(email_to, acc.get("display_name", ""), reset_link)
return {
"success": True,
"step": "sent",
"message": (
"Ein Link zum Zurücksetzen wurde an die hinterlegte E-Mail-Adresse gesendet."
),
}
# =====================================================================
# 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 _LEGACY_DEFAULT_PID
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 und eine neue Praxis."""
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() or "Meine Praxis"
admin_email = (body.get("email") or "").strip()
pid = (body.get("practice_id") 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 not pid:
pid = _generate_practice_id()
practice = _ensure_practice(pid, name=practice_name, admin_email=admin_email)
accounts = _load_accounts()
practice_accounts = [a for a in accounts.values()
if a.get("practice_id") == pid]
if practice_accounts:
raise HTTPException(status_code=409,
detail="Setup bereits abgeschlossen. Bitte Login verwenden.")
if practice_name:
practices = _load_practices()
practices[pid]["name"] = practice_name
if admin_email:
practices[pid]["admin_email"] = admin_email
_save_practices(practices)
practice = practices[pid]
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": pid,
"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(pid)
ua = request.headers.get("User-Agent", "")
ip = _extract_client_ip(request)
dev_id = _make_device_id(uid, ua)
token = _create_session(uid, pid, 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": pid,
"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 Benutzername (Anzeigename) oder E-Mail + Passwort, mandantenbewusst."""
try:
body = await request.json()
except Exception:
body = {}
raw = (body.get("name") or "").strip()
password = (body.get("password") or "").strip()
pid = _practice_id_from_client(request, body)
if not pid:
pid = _resolve_practice_id(request)
if not raw or not password:
raise HTTPException(
status_code=400,
detail="Benutzername oder E-Mail und Passwort erforderlich",
)
accounts = _load_accounts()
scoped = (
[a for a in accounts.values() if a.get("practice_id") == pid]
if pid
else list(accounts.values())
)
target = None
if _is_likely_email(raw):
em = _norm_email(raw)
matches = [
a
for a in scoped
if em and _norm_email(a.get("email") or "") == em
]
if len(matches) == 1:
target = matches[0]
elif len(matches) == 0:
target = None
else:
if pid:
raise HTTPException(
status_code=409,
detail={
"code": "ambiguous_email",
"message": (
"Mehrere Benutzer mit dieser E-Mail in dieser Praxis. "
"Bitte melden Sie sich mit Ihrem Benutzernamen an."
),
"candidates": [
{"display_name": (a.get("display_name") or "")}
for a in matches
],
},
)
raise HTTPException(
status_code=409,
detail={
"code": "ambiguous_email",
"message": (
"Diese E-Mail ist mehreren Konten zugeordnet. "
"Bitte melden Sie sich mit Ihrem Benutzernamen an."
),
},
)
else:
matches = [a for a in scoped if (a.get("display_name") or "") == raw]
if len(matches) == 1:
target = matches[0]
elif len(matches) == 0:
target = None
else:
raise HTTPException(
status_code=409,
detail={
"code": "ambiguous_username",
"message": (
"Dieser Benutzername ist mehrdeutig. Bitte wenden Sie sich an Ihre Praxis."
),
},
)
if not target:
raise HTTPException(
status_code=401,
detail="Benutzer nicht gefunden oder falsches Passwort",
)
if not pid:
pid = target.get("practice_id", "")
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="Benutzer nicht gefunden oder falsches Passwort",
)
now = time.strftime("%Y-%m-%d %H:%M:%S")
target["last_login"] = now
_save_accounts(accounts)
dn = (target.get("display_name") or raw).strip()
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, dn, 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": dn,
"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 Chat-Einladungscode (Admin-Session oder API-Token)."""
api_token = request.headers.get("X-API-Token", "")
s = _session_from_request(request)
if s:
if s.get("role") != "admin":
raise HTTPException(status_code=403, detail="Nur Admin darf Einladungscode erneuern")
pid = s["practice_id"]
elif api_token:
pid = request.headers.get("X-Practice-Id", "").strip()
if not pid:
raise HTTPException(status_code=400, detail="X-Practice-Id Header erforderlich")
else:
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
_ensure_practice(pid)
practices = _load_practices()
if pid in practices:
practices[pid]["invite_code"] = _generate_chat_invite_code()
_save_practices(practices)
return JSONResponse(content={
"success": True,
"invite_code": practices.get(pid, {}).get("invite_code", ""),
})
@router.post("/auth/provision")
async def auth_provision(request: Request):
"""Provisioning: Desktop-App erstellt/findet Server-Account.
Authentifiziert via X-API-Token (Backend-Token), nicht via Session.
Erstellt bei Bedarf eine neue Praxis mit echter practice_id."""
api_token = request.headers.get("X-API-Token", "")
if not api_token:
raise HTTPException(status_code=401, detail="API-Token erforderlich")
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
email = (body.get("email") or "").strip()
password = (body.get("password") or "").strip()
practice_name = (body.get("practice_name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Name erforderlich")
if not password or len(password) < 4:
raise HTTPException(status_code=400, detail="Passwort (min. 4 Zeichen) erforderlich")
pid = request.headers.get("X-Practice-Id", "").strip()
pid = pid or (body.get("practice_id") or "").strip()
if not pid and email:
try:
from stripe_routes import lookup_practice_id_for_license_email
lp = lookup_practice_id_for_license_email(email)
if lp:
pid = lp.strip()
except Exception as exc:
print(f"[EMPFANG] lookup_practice_id_for_license_email: {exc}")
if not pid:
practices = _load_practices()
has_legacy = _LEGACY_DEFAULT_PID in practices
accounts = _load_accounts()
has_legacy_accounts = any(
a.get("practice_id") == _LEGACY_DEFAULT_PID for a in accounts.values())
if has_legacy or has_legacy_accounts:
pid = _generate_practice_id()
_migrate_legacy_to_practice(pid)
else:
pid = _generate_practice_id()
practice = _ensure_practice(pid, name=practice_name or "Meine Praxis",
admin_email=email)
if practice_name:
practices = _load_practices()
practices[pid]["name"] = practice_name
if email:
practices[pid]["admin_email"] = email
_save_practices(practices)
accounts = _load_accounts()
target = None
email_lower = email.lower() if email else ""
for a in accounts.values():
if a.get("practice_id") != pid:
continue
if email_lower and (a.get("email") or "").strip().lower() == email_lower:
target = a
break
if a["display_name"] == name:
target = a
break
if target:
pw_hash, pw_salt = _hash_password(password)
target["pw_hash"] = pw_hash
target["pw_salt"] = pw_salt
if email:
target["email"] = email
target["display_name"] = name
if not target.get("role") or target.get("role") == "mpa":
has_admin = any(a.get("role") == "admin" and a.get("practice_id") == pid
for a in accounts.values())
if not has_admin:
target["role"] = "admin"
_save_accounts(accounts)
return JSONResponse(content={
"success": True, "user_id": target["user_id"],
"display_name": target["display_name"], "role": target["role"],
"practice_id": pid,
"action": "updated",
})
has_admin = any(a.get("role") == "admin" and a.get("practice_id") == pid
for a in accounts.values())
role = "admin" if not has_admin else "arzt"
uid = uuid.uuid4().hex[:12]
pw_hash, pw_salt = _hash_password(password)
accounts[uid] = {
"user_id": uid,
"practice_id": pid,
"display_name": name,
"email": email,
"role": role,
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"status": "active",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_save_accounts(accounts)
return JSONResponse(content={
"success": True, "user_id": uid,
"display_name": name, "role": role,
"practice_id": pid,
"action": "created",
})
@router.post("/auth/forgot_password")
async def auth_forgot_password(request: Request):
"""Passwort-Reset: Benutzername oder E-Mail; bei mehreren Konten mit gleicher E-Mail zweiter Schritt."""
try:
body = await request.json()
except Exception:
body = {}
raw = (body.get("login") or body.get("email") or body.get("name") or "").strip()
chosen_display_name = (body.get("display_name") or body.get("username") or "").strip()
pid = _practice_id_from_client(request, body)
if not raw:
raise HTTPException(
status_code=400, detail="Benutzername oder E-Mail erforderlich",
)
accounts = _load_accounts()
practices = _load_practices()
_neutral = {
"success": True,
"step": "sent",
"message": (
"Wenn ein passendes Konto existiert, wurde ein Link an die hinterlegte "
"E-Mail-Adresse gesendet."
),
}
# Zweiter Schritt: E-Mail + gewählter Benutzername (eindeutig)
if chosen_display_name and _is_likely_email(raw):
if not pid:
raise HTTPException(
status_code=400,
detail=(
"Praxis-Kontext fehlt. Bitte laden Sie die Seite neu oder "
"kontaktieren Sie Ihre Praxis."
),
)
em = _norm_email(raw)
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
picked = [
a
for a in scoped
if _norm_email(a.get("email") or "") == em
and (a.get("display_name") or "") == chosen_display_name
]
if len(picked) == 1:
return JSONResponse(content=_send_reset_for_account(picked[0]))
return JSONResponse(content=_neutral)
if _is_likely_email(raw):
em = _norm_email(raw)
if pid:
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
matches = [a for a in scoped if _norm_email(a.get("email") or "") == em]
else:
matches = [
a for a in accounts.values()
if _norm_email(a.get("email") or "") == em
]
if len(matches) == 0:
return JSONResponse(content=_neutral)
if len(matches) == 1:
return JSONResponse(content=_send_reset_for_account(matches[0]))
cands = []
for a in matches:
pida = a.get("practice_id", "")
cands.append({
"display_name": a.get("display_name", ""),
"practice_id": pida,
"practice_name": _practice_label(practices, pida),
})
return JSONResponse(
content={
"success": True,
"step": "pick_user",
"login": raw,
"candidates": cands,
},
)
if pid:
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
matches = [a for a in scoped if (a.get("display_name") or "") == raw]
else:
matches = [
a for a in accounts.values()
if (a.get("display_name") or "") == raw
]
if len(matches) == 0:
return JSONResponse(content=_neutral)
if len(matches) > 1:
return JSONResponse(
content={
"success": False,
"step": "ambiguous_practice",
"message": (
"Dieser Benutzername ist in mehreren Praxen registriert. Bitte "
"setzen Sie das Passwort über Ihre E-Mail-Adresse zurück oder "
"wenden Sie sich an Ihre Praxis."
),
},
)
return JSONResponse(content=_send_reset_for_account(matches[0]))
@router.get("/auth/reset_verify")
async def auth_reset_verify(reset_token: str = Query("")):
"""Prüft, ob ein Reset-Token noch gültig ist (ohne Verbrauch)."""
token = (reset_token or "").strip()
if not token:
return JSONResponse(
content={"valid": False, "detail": "Kein Reset-Token angegeben."}
)
resets = _load_json(_DATA_DIR / "empfang_resets.json", {})
entry = resets.get(token)
if not entry:
return JSONResponse(
content={"valid": False, "detail": "Ungültiger oder abgelaufener Link."}
)
if time.time() - entry.get("created", 0) > 3600:
return JSONResponse(
content={
"valid": False,
"detail": "Der Link ist abgelaufen (max. 1 Stunde).",
}
)
email = (entry.get("email") or "").strip()
accounts = _load_accounts()
uid = entry.get("user_id")
acc = accounts.get(uid) if uid else None
display_name = (entry.get("display_name") or (acc or {}).get("display_name") or "").strip()
return JSONResponse(
content={"valid": True, "email": email, "display_name": display_name}
)
@router.post("/auth/reset_password")
async def auth_reset_password(request: Request):
"""Setzt das Passwort mit einem gültigen 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="Ungültiger 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)
acc = accounts[user_id]
email_hint = (acc.get("email") or "").strip()
dn_hint = (acc.get("display_name") or "").strip()
return JSONResponse(
content={
"success": True,
"message": "Passwort wurde erfolgreich geändert.",
"email": email_hint,
"display_name": dn_hint,
}
)
def _reset_email_subject_body(display_name: str, reset_link: str) -> Tuple[str, str, str]:
"""Betreff, Plain-Text, HTML für Passwort-Reset."""
subject = "AZA Praxis-Chat Passwort zurücksetzen"
text = (
f"Hallo {display_name},\n\n"
f"Sie haben eine Passwort-Zurücksetzung 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 gültig.\n\n"
f"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
f"AZA Praxis-Chat"
)
html = (
f"<div style='font-family:Segoe UI,sans-serif;max-width:480px;margin:0 auto'>"
f"<h2 style='color:#5B8DB3'>Passwort zurücksetzen</h2>"
f"<p>Hallo {display_name},</p>"
f"<p>Sie haben eine Passwort-Zurücksetzung angefordert.</p>"
f"<p><a href='{reset_link}' style='display:inline-block;background:#5B8DB3;"
f"color:white;padding:10px 24px;border-radius:6px;text-decoration:none;"
f"font-weight:600'>Neues Passwort speichern</a></p>"
f"<p style='color:#888;font-size:13px'>Der Link ist 1 Stunde gültig.</p>"
f"<p style='color:#888;font-size:12px'>Falls Sie diese Anfrage nicht gestellt haben, "
f"ignorieren Sie diese E-Mail.</p></div>"
)
return subject, text, html
def _send_reset_via_resend(to_email: str, subject: str, text: str, html: str) -> bool:
"""Resend HTTP API (gleiche Umgebung wie Lizenz-Mail in stripe_routes)."""
import json
import urllib.error
import urllib.request
api_key = os.environ.get("RESEND_API_KEY", "").strip()
sender = os.environ.get("MAIL_FROM", "AZA MedWork <noreply@aza-medwork.ch>").strip()
if not api_key:
return False
payload = json.dumps({
"from": sender,
"to": [to_email],
"subject": subject,
"html": html,
"text": text,
}).encode("utf-8")
req = urllib.request.Request(
"https://api.resend.com/emails",
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "AZA-MedWork/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status in (200, 201):
print(f"[RESET-MAIL] Resend OK -> {to_email}")
return True
body = resp.read().decode()[:300]
print(f"[RESET-MAIL] Resend HTTP {resp.status}: {body}")
return False
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:300] if exc.fp else ""
print(f"[RESET-MAIL] Resend HTTP {exc.code}: {body}")
return False
except Exception as exc:
print(f"[RESET-MAIL] Resend {type(exc).__name__}: {exc}")
return False
def _send_reset_email(to_email: str, display_name: str, reset_link: str):
"""Sendet Passwort-Reset: zuerst SMTP (falls vollstaendig), sonst Resend API."""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
subject, text, html = _reset_email_subject_body(display_name, reset_link)
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 all([host, user, password]):
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] SMTP OK -> {to_email}")
return
except Exception as exc:
print(f"[RESET-MAIL] SMTP FEHLER: {exc} versuche Resend …")
if _send_reset_via_resend(to_email, subject, text, html):
return
print(
"[RESET-MAIL] Weder SMTP noch Resend erfolgreich. "
"Setzen Sie SMTP_HOST/SMTP_USER/SMTP_PASS oder RESEND_API_KEY (+ MAIL_FROM). "
f"Reset-Link (nur Server-Log): {reset_link}"
)
@router.get("/auth/needs_setup")
async def auth_needs_setup(request: Request):
"""Pruefen ob Setup noetig ist (keine Accounts vorhanden)."""
pid = _resolve_practice_id(request)
if not pid:
accounts = _load_accounts()
return JSONResponse(content={
"needs_setup": len(accounts) == 0,
"invite_code": "",
})
_ensure_practice(pid)
accounts = _load_accounts()
has_accounts = any(a.get("practice_id") == pid for a in accounts.values())
practices = _load_practices()
invite_code = practices.get(pid, {}).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"],
})
api_token = request.headers.get("X-API-Token", "")
pid = _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"users": [], "practice_id": ""})
users = _practice_users(pid)
if users:
result: dict = {
"users": [u["display_name"] for u in users],
"practice_id": pid,
}
if api_token:
devices = _load_devices()
user_devices: dict[str, list] = {}
for d in devices.values():
if d.get("practice_id") != pid:
continue
uid = d.get("user_id", "")
user_devices.setdefault(uid, []).append({
"device_name": d.get("device_name", ""),
"platform": d.get("platform", ""),
"last_active": d.get("last_active", ""),
"ip_last": d.get("ip_last", ""),
})
for u in users:
u["devices"] = user_devices.get(u.get("user_id", ""), [])
result["users_full"] = users
return JSONResponse(content=result)
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 = _resolve_practice_id(request)
if not pid or 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 _resolve_practice_id(request)
if not pid:
raise HTTPException(status_code=400, detail="practice_id erforderlich")
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)):
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "messages": []})
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)):
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "messages": []})
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):
pid = _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "tasks": []})
tasks = _load_tasks()
filtered = [t for t in tasks if t.get("practice_id") == pid]
return JSONResponse(content={"success": True, "tasks": filtered})
@router.post("/tasks")
async def empfang_tasks_create(request: Request):
pid = _require_practice_id(request)
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))
pid = (body.get("practice_id") or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "removed": 0, "remaining": 0})
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):
api_token = request.headers.get("X-API-Token", "")
s = _session_from_request(request)
pid = _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"practice_id": "", "practice_name": "",
"user_count": 0, "message_count": 0, "open_count": 0})
_ensure_practice(pid)
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") or api_token:
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="<h1>empfang.html nicht gefunden</h1>", status_code=404)