Files
aza/AzA march 2026/empfang_routes.py
2026-04-19 22:22:11 +02:00

767 lines
26 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 V4.
Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben.
Alle Daten practice-scoped. Backend ist die einzige Wahrheit.
"""
import hashlib
import hmac
import json
import os
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"
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"),
}
_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
# =====================================================================
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) -> 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,
"created": time.time(),
"last_active": time.time(),
}
_save_sessions(sessions)
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
# =====================================================================
# 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)
# =====================================================================
# 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()
if not name or not password or len(password) < 4:
raise HTTPException(status_code=400,
detail="Name und Passwort (min. 4 Zeichen) erforderlich")
uid = uuid.uuid4().hex[:12]
pw_hash, pw_salt = _hash_password(password)
accounts[uid] = {
"user_id": uid,
"practice_id": DEFAULT_PRACTICE_ID,
"display_name": name,
"role": "admin",
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_save_accounts(accounts)
token = _create_session(uid, DEFAULT_PRACTICE_ID, name, "admin")
resp = JSONResponse(content={
"success": True, "user_id": uid, "role": "admin",
"display_name": name, "practice_id": DEFAULT_PRACTICE_ID,
"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 + 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 und Passwort erforderlich")
accounts = _load_accounts()
target = None
for a in accounts.values():
if a["display_name"] == name and a.get("practice_id") == pid:
target = a
break
if not target:
raise HTTPException(status_code=401, detail="Benutzer nicht gefunden")
if not _verify_password(password, target["pw_hash"], target["pw_salt"]):
raise HTTPException(status_code=401, detail="Falsches Passwort")
token = _create_session(target["user_id"], pid, name, target["role"])
resp = JSONResponse(content={
"success": True, "user_id": target["user_id"], "role": target["role"],
"display_name": name, "practice_id": pid,
})
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()
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 ("admin", "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)
accounts[uid] = {
"user_id": uid,
"practice_id": target_pid,
"display_name": name,
"role": role,
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_save_accounts(accounts)
token = _create_session(uid, target_pid, name, role)
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.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 "",
})
# =====================================================================
# 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"),
}
_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})
# =====================================================================
# 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", "")
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)