2208 lines
77 KiB
Python
2208 lines
77 KiB
Python
# -*- 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",
|
||
# Desktop-Huelle (iframe-Modus): erlaubt Einbettung aus pywebview/null-Origin
|
||
"Content-Security-Policy": "frame-ancestors *;",
|
||
},
|
||
)
|
||
return HTMLResponse(content="<h1>empfang.html nicht gefunden</h1>", status_code=404)
|