9347 lines
339 KiB
Python
9347 lines
339 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 base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
from collections import defaultdict
|
||
import secrets
|
||
import tempfile
|
||
import time
|
||
import unicodedata
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Optional, Tuple
|
||
|
||
import asyncio
|
||
|
||
from fastapi import (
|
||
APIRouter,
|
||
Cookie,
|
||
File,
|
||
Form,
|
||
HTTPException,
|
||
Query,
|
||
Request,
|
||
Response,
|
||
UploadFile,
|
||
WebSocket,
|
||
WebSocketDisconnect,
|
||
)
|
||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
router = APIRouter()
|
||
_log = logging.getLogger(__name__)
|
||
|
||
_DATA_DIR = Path(__file__).resolve().parent / "data"
|
||
_EMPFANG_FILE = _DATA_DIR / "empfang_nachrichten.json"
|
||
_EXTERNAL_DM_MESSAGES_FILE = _DATA_DIR / "empfang_external_messages.json"
|
||
_EXTERNAL_DM_TEXT_MAX = 16000
|
||
_EXTERNAL_DM_READS_FILE = _DATA_DIR / "empfang_external_dm_reads.json"
|
||
_ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024
|
||
_ATTACHMENT_MAX_PER_MESSAGE = 3
|
||
_ATTACHMENT_ALLOWED_MIME = frozenset({"image/png", "image/jpeg", "image/webp"})
|
||
_ATTACHMENTS_DIR = _DATA_DIR / "empfang_attachments"
|
||
_ATTACHMENTS_META_FILE = _DATA_DIR / "empfang_attachments_meta.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
|
||
RESET_LINK_TTL_SEC = min(
|
||
max(3600, int(os.environ.get("EMPFANG_RESET_TTL_SECONDS", str(86400)))),
|
||
7 * 86400,
|
||
)
|
||
|
||
|
||
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 _invite_code_key(raw: str) -> str:
|
||
"""Vergleicht Einladungscodes unabhaengig von Leerzeichen und Gedankenstrich-Varianten."""
|
||
s = (raw or "").strip().upper().replace(" ", "")
|
||
for ch in ("\u2011", "\u2013", "\u2014", "\u2212"):
|
||
s = s.replace(ch, "-")
|
||
return s
|
||
|
||
|
||
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,
|
||
"specialty": "",
|
||
"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())
|
||
ln_mu = _preferred_unique_login_for_display(
|
||
accounts, practice_id, name, "",
|
||
)
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": practice_id,
|
||
"display_name": name,
|
||
"login_name": ln_mu,
|
||
"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"],
|
||
"login_name": (a.get("login_name") or "").strip(),
|
||
"email": (a.get("email") or "").strip(),
|
||
"has_password_hash": bool((a.get("pw_hash") or "").strip()),
|
||
"specialty": (a.get("specialty") or "").strip(),
|
||
"title": (a.get("title") or "").strip(),
|
||
}
|
||
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 _audit_invite_tail(raw: str) -> str:
|
||
"""Letzte Zeichen des Codes fuer Logs (kein vollstaendiger Invite/CHAT-Code)."""
|
||
s = (raw or "").strip().upper().replace(" ", "")
|
||
if len(s) < 5:
|
||
return "----"
|
||
return s[-4:]
|
||
|
||
|
||
def _lookup_practice_id_by_invite(invite_raw: str) -> str:
|
||
"""Liefert practice_id fuer einen Einladungscode (normalisierter Vergleich) oder ''."""
|
||
if not (invite_raw or "").strip():
|
||
return ""
|
||
practices = _load_practices()
|
||
want = _invite_code_key(invite_raw)
|
||
for pida, pdata in practices.items():
|
||
if _invite_code_key(pdata.get("invite_code")) == want:
|
||
return pida
|
||
return ""
|
||
|
||
|
||
def _practice_id_for_login(request: Request, body: dict) -> tuple[str, str]:
|
||
"""Login: practice_id ohne Session-Cookie (sonst ueberholt alte Session die Einladung).
|
||
|
||
Reihenfolge: invite_code > Body > Header > Query.
|
||
Rueckgabe: (practice_id, quelle) mit quelle in
|
||
'invite'|'body'|'header'|'query'|''|'invite_invalid'
|
||
"""
|
||
invite_raw = (body.get("invite_code") or "").strip()
|
||
if invite_raw:
|
||
got = _lookup_practice_id_by_invite(invite_raw)
|
||
if got:
|
||
return got, "invite"
|
||
return "", "invite_invalid"
|
||
pid = (body.get("practice_id") or "").strip()
|
||
if pid:
|
||
return pid, "body"
|
||
pid = request.headers.get("X-Practice-Id", "").strip()
|
||
if pid:
|
||
return pid, "header"
|
||
pid = request.query_params.get("practice_id", "").strip()
|
||
if pid:
|
||
return pid, "query"
|
||
return "", ""
|
||
|
||
|
||
def _practice_label(practices: dict, pid: str) -> str:
|
||
p = practices.get(pid) or {}
|
||
return (p.get("name") or "").strip() or pid
|
||
|
||
|
||
def _invite_join_clone_account(
|
||
accounts: dict,
|
||
source: dict,
|
||
target_pid: str,
|
||
now: str,
|
||
) -> dict:
|
||
"""Second account in target practice with same credentials (cross-practice chat join).
|
||
|
||
Verhindert stillen Login in der alten Praxis, wenn ein gueltiger Einladungscode
|
||
die Ziel-practice_id festlegt. Keine Praxis-Migration: Quellkonto bleibt unveraendert.
|
||
"""
|
||
display = (source.get("display_name") or "").strip()
|
||
if not display:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Ungueltiges Quellkonto.",
|
||
)
|
||
if any(
|
||
_normalize_login_username(a.get("display_name") or "")
|
||
== _normalize_login_username(display)
|
||
and a.get("practice_id") == target_pid
|
||
for a in accounts.values()
|
||
):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=(
|
||
"In dieser Praxis existiert bereits ein Benutzer mit diesem Namen. "
|
||
"Bitte melden Sie sich mit diesem Konto an."
|
||
),
|
||
)
|
||
uid = uuid.uuid4().hex[:12]
|
||
pref_ln = (source.get("login_name") or "").strip() or display
|
||
ln_assign = _preferred_unique_login_for_display(accounts, target_pid, pref_ln, "")
|
||
role_s = (source.get("role") or "mpa").strip()
|
||
if role_s == "admin":
|
||
role_s = "mpa"
|
||
elif role_s not in ("arzt", "mpa", "empfang"):
|
||
role_s = "mpa"
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": target_pid,
|
||
"display_name": display,
|
||
"email": (source.get("email") or "").strip(),
|
||
"login_name": ln_assign,
|
||
"role": role_s,
|
||
"pw_hash": source["pw_hash"],
|
||
"pw_salt": source["pw_salt"],
|
||
"created": now,
|
||
"status": "active",
|
||
"last_login": now,
|
||
}
|
||
if source.get("must_change_password"):
|
||
accounts[uid]["must_change_password"] = True
|
||
return accounts[uid]
|
||
|
||
|
||
def _mask_email_for_response(addr: str) -> str:
|
||
"""Kurze Maskierung fuer API-Antworten (kein Klartext der vollstaendigen Adresse)."""
|
||
s = (addr or "").strip()
|
||
if "@" not in s:
|
||
return ""
|
||
local, _, domain = s.partition("@")
|
||
dom = domain.strip()
|
||
loc = local.strip()
|
||
if not loc or not dom:
|
||
return ""
|
||
head = loc[0] if loc else "?"
|
||
return f"{head}***@{dom}"
|
||
|
||
|
||
def _forgot_password_neutral_payload() -> dict:
|
||
"""Gleiche Nutzer-Meldung wie zuvor, aber ohne falsche Zustellungs-Zusicherung."""
|
||
return {
|
||
"success": True,
|
||
"step": "none",
|
||
"message": (
|
||
"Wenn ein passendes Konto existiert, wurde ein Link an die hinterlegte "
|
||
"E-Mail-Adresse gesendet."
|
||
),
|
||
"reset_token_created": False,
|
||
"target_email_masked": "",
|
||
"mail_delivered": False,
|
||
"attempted_delivery": False,
|
||
}
|
||
|
||
|
||
def _send_reset_for_account(acc: dict) -> dict:
|
||
"""Token erstellen, Mail senden — ehrliche Statusfelder ohne Token-Leak."""
|
||
pid_acc = (acc.get("practice_id") or "").strip()
|
||
practices = _load_practices()
|
||
|
||
email_to = (acc.get("email") or "").strip()
|
||
if not email_to and pid_acc:
|
||
email_to = ((practices.get(pid_acc) or {}).get("admin_email") or "").strip()
|
||
if not email_to and pid_acc:
|
||
try:
|
||
from stripe_routes import lookup_license_email_for_practice
|
||
|
||
le = (lookup_license_email_for_practice(pid_acc) or "").strip()
|
||
if le:
|
||
email_to = le
|
||
except Exception:
|
||
pass
|
||
|
||
if not email_to:
|
||
return {
|
||
"success": False,
|
||
"step": "no_email",
|
||
"message": (
|
||
"Fuer dieses Konto ist keine direkte E-Mail hinterlegt und keine "
|
||
"gueltige Praxis-/Lizenz-E-Mail wurde gefunden. Bitte einen Administrator "
|
||
"in der Hauptinstallation bitten oder Passwort dort setzen."
|
||
),
|
||
"reset_token_created": False,
|
||
"target_email_masked": "",
|
||
"mail_delivered": False,
|
||
"attempted_delivery": False,
|
||
}
|
||
|
||
masked = _mask_email_for_response(email_to)
|
||
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": pid_acc,
|
||
"created": time.time(),
|
||
"delivery_email": email_to.strip(),
|
||
}
|
||
for k in list(resets.keys()):
|
||
if time.time() - resets[k].get("created", 0) > RESET_LINK_TTL_SEC:
|
||
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}"
|
||
ok = _send_reset_email(email_to, acc.get("display_name", ""), reset_link)
|
||
if not ok:
|
||
if reset_token in resets:
|
||
del resets[reset_token]
|
||
_save_json(_DATA_DIR / "empfang_resets.json", resets)
|
||
return {
|
||
"success": False,
|
||
"step": "mail_failed",
|
||
"message": (
|
||
"Der Reset-Link konnte nicht per E-Mail zugestellt werden. "
|
||
"Bitte konfigurieren Sie SMTP oder RESEND_API_KEY auf dem Server "
|
||
"oder wenden Sie sich an Ihren Administrator."
|
||
),
|
||
"reset_token_created": False,
|
||
"target_email_masked": masked,
|
||
"mail_delivered": False,
|
||
"attempted_delivery": True,
|
||
}
|
||
return {
|
||
"success": True,
|
||
"step": "sent",
|
||
"message": (
|
||
"Ein Link zum Zurücksetzen wurde an die hinterlegte E-Mail-Adresse gesendet."
|
||
),
|
||
"reset_token_created": True,
|
||
"target_email_masked": masked,
|
||
"mail_delivered": True,
|
||
"attempted_delivery": True,
|
||
}
|
||
|
||
|
||
# =====================================================================
|
||
# 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 _record_practice_new_device_notice(
|
||
practice_id: str, user_id: str, device_id: str, ip_addr: str
|
||
) -> None:
|
||
pid = (practice_id or "").strip()
|
||
if not pid or not device_id:
|
||
return
|
||
practices = _load_practices()
|
||
p = practices.get(pid)
|
||
if not isinstance(p, dict):
|
||
return
|
||
alerts = list(p.get("pdevice_alerts") or [])
|
||
alerts.append(
|
||
{
|
||
"ts": time.time(),
|
||
"user_id": (user_id or "").strip(),
|
||
"device_suffix": str(device_id)[-8:],
|
||
"ip": (ip_addr or "").strip(),
|
||
}
|
||
)
|
||
p["pdevice_alerts"] = alerts[-80:]
|
||
practices[pid] = p
|
||
_save_practices(practices)
|
||
|
||
|
||
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,
|
||
}
|
||
try:
|
||
_record_practice_new_device_notice(practice_id, user_id, device_id, ip_addr)
|
||
except Exception:
|
||
pass
|
||
|
||
_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)
|
||
# =====================================================================
|
||
|
||
EMPFANG_MESSAGE_RETENTION_DAYS = 14
|
||
|
||
|
||
def _utc_now_iso_z() -> str:
|
||
"""UTC-Zeitstempel fuer Chat-Nachrichten, ISO-8601 mit Z (eindeutig UTC)."""
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
def _parse_msg_instant_utc_ts(raw: str) -> float:
|
||
"""Parse eines gespeicherten Chat-Zeitstempels zu POSIX-Sekunden (UTC).
|
||
Naive Legacy-Strings ('YYYY-MM-DD HH:MM:SS' / '...T...' ohne TZ) werden
|
||
wie in Produktion ueblich als UTC interpretiert (Server/VPS typisch)."""
|
||
s = (raw or "").strip()
|
||
if not s:
|
||
return 0.0
|
||
s_norm = s.replace(" ", "T", 1)
|
||
try:
|
||
if s_norm.endswith("Z"):
|
||
dt = datetime.fromisoformat(s_norm.replace("Z", "+00:00"))
|
||
return dt.timestamp()
|
||
dt = datetime.fromisoformat(s_norm)
|
||
if dt.tzinfo is not None:
|
||
return dt.timestamp()
|
||
return dt.replace(tzinfo=timezone.utc).timestamp()
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
def _msg_timestamp_for_retention(m: dict) -> str:
|
||
return (m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
|
||
|
||
def _msg_chrono_sort_key(m: dict) -> tuple[float, str]:
|
||
raw = _msg_timestamp_for_retention(m)
|
||
return (_parse_msg_instant_utc_ts(raw), str(m.get("id") or ""))
|
||
|
||
|
||
def _message_within_retention(m: dict, cutoff_ts: float) -> bool:
|
||
"""Behalten wenn Zeitstempel unbekannt oder Augenblick >= cutoff (UTC-Sekunden)."""
|
||
t = _msg_timestamp_for_retention(m)
|
||
if not t:
|
||
return True
|
||
ts = _parse_msg_instant_utc_ts(t)
|
||
if ts <= 0:
|
||
return True
|
||
return ts >= cutoff_ts
|
||
|
||
|
||
def _prune_messages_by_retention(messages: list[dict]) -> tuple[list[dict], int]:
|
||
cutoff_ts = time.time() - EMPFANG_MESSAGE_RETENTION_DAYS * 86400
|
||
kept = [m for m in messages if _message_within_retention(m, cutoff_ts)]
|
||
return kept, len(messages) - len(kept)
|
||
|
||
|
||
def _load_messages() -> list[dict]:
|
||
messages = _load_json(_EMPFANG_FILE, [])
|
||
kept, removed = _prune_messages_by_retention(messages)
|
||
if removed > 0:
|
||
_save_messages(kept)
|
||
return kept
|
||
|
||
|
||
def _save_messages(messages: list[dict]):
|
||
_save_json(_EMPFANG_FILE, messages)
|
||
|
||
|
||
def _load_external_dm_messages() -> list[dict]:
|
||
"""Cross-Praxis-Direktchat (separate Datei, kein Mix mit internen DMs)."""
|
||
data = _load_json(_EXTERNAL_DM_MESSAGES_FILE, [])
|
||
if not isinstance(data, list):
|
||
return []
|
||
kept, removed = _prune_messages_by_retention(data)
|
||
if removed > 0:
|
||
_save_external_dm_messages(kept)
|
||
return kept
|
||
|
||
|
||
def _save_external_dm_messages(messages: list[dict]):
|
||
_save_json(_EXTERNAL_DM_MESSAGES_FILE, messages)
|
||
|
||
|
||
def _external_dm_conversation_id(pid_a: str, uid_a: str, pid_b: str, uid_b: str) -> str:
|
||
"""Stabile, symmetrische Konversations-ID; Kollision mit internem direct_conv_key ausgeschlossen."""
|
||
t1 = f"{(pid_a or '').strip()}\t{(uid_a or '').strip()}"
|
||
t2 = f"{(pid_b or '').strip()}\t{(uid_b or '').strip()}"
|
||
lo, hi = (t1, t2) if t1 <= t2 else (t2, t1)
|
||
raw = f"external_dm|{lo}|{hi}"
|
||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||
|
||
|
||
def _account_record_for_practice(uid: str, pid: str) -> Optional[dict]:
|
||
if not uid or not pid:
|
||
return None
|
||
accounts = _load_accounts()
|
||
acc = accounts.get(uid)
|
||
if not isinstance(acc, dict):
|
||
return None
|
||
if (acc.get("practice_id") or "").strip() != pid:
|
||
return None
|
||
return acc
|
||
|
||
|
||
def _account_is_sendable(acc: Optional[dict]) -> bool:
|
||
if not acc:
|
||
return False
|
||
st = str(acc.get("status") or "active").strip().lower()
|
||
return st not in ("deactivated", "deleted", "inactive")
|
||
|
||
|
||
def _external_dm_authorize_send_direction(
|
||
sender_pid: str,
|
||
sender_uid: str,
|
||
recipient_pid: str,
|
||
recipient_uid: str,
|
||
) -> tuple[str, dict]:
|
||
"""Richtungsbezogen: darf sender an recipient schreiben? Liefert (link_id, link)."""
|
||
sender_pid = (sender_pid or "").strip()
|
||
sender_uid = (sender_uid or "").strip()
|
||
recipient_pid = (recipient_pid or "").strip()
|
||
recipient_uid = (recipient_uid or "").strip()
|
||
if not sender_pid or not sender_uid or not recipient_pid or not recipient_uid:
|
||
raise HTTPException(status_code=400, detail="Adressaten unvollstaendig")
|
||
if sender_pid == recipient_pid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Innerhalb einer Praxis bitte den bestehenden Direktchat nutzen.",
|
||
)
|
||
s_acc = _account_record_for_practice(sender_uid, sender_pid)
|
||
r_acc = _account_record_for_practice(recipient_uid, recipient_pid)
|
||
if not _account_is_sendable(s_acc):
|
||
raise HTTPException(status_code=403, detail="Absenderkonto nicht zulaessig")
|
||
if not _account_is_sendable(r_acc):
|
||
raise HTTPException(status_code=403, detail="Empfaenger nicht erreichbar")
|
||
|
||
store = _load_practice_links_store()
|
||
practice_link: Optional[dict] = None
|
||
person_link: Optional[dict] = None
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
st = str(link.get("status") or "").strip().lower()
|
||
if st != "accepted":
|
||
continue
|
||
a = (link.get("source_practice_id") or "").strip()
|
||
b = (link.get("target_practice_id") or "").strip()
|
||
if {a, b} != {sender_pid, recipient_pid}:
|
||
continue
|
||
ct = _link_contact_type(link)
|
||
if ct == _CONTACT_TYPE_PRACTICE:
|
||
practice_link = link
|
||
elif ct == _CONTACT_TYPE_PERSON:
|
||
person_link = link
|
||
|
||
if practice_link is not None:
|
||
lid = str(practice_link.get("id") or "").strip()
|
||
if not lid:
|
||
raise HTTPException(status_code=500, detail="Verbindungsdaten ungueltig")
|
||
return lid, practice_link
|
||
|
||
if person_link is None:
|
||
raise HTTPException(status_code=403, detail="Keine freigegebene Verbindung")
|
||
|
||
sp = (person_link.get("source_practice_id") or "").strip()
|
||
tp = (person_link.get("target_practice_id") or "").strip()
|
||
su = (person_link.get("source_user_id") or "").strip()
|
||
tu = _effective_person_target_user_id(person_link)
|
||
if not su or not tu:
|
||
raise HTTPException(status_code=403, detail="Keine freigegebene Verbindung")
|
||
|
||
# Strikt 1:1 — nur su darf mit tu schreiben und umgekehrt.
|
||
if sender_pid == sp and sender_uid == su and recipient_pid == tp and recipient_uid == tu:
|
||
lid = str(person_link.get("id") or "").strip()
|
||
if lid:
|
||
return lid, person_link
|
||
if sender_pid == tp and sender_uid == tu and recipient_pid == sp and recipient_uid == su:
|
||
lid = str(person_link.get("id") or "").strip()
|
||
if lid:
|
||
return lid, person_link
|
||
|
||
raise HTTPException(status_code=403, detail="Keine freigegebene Verbindung")
|
||
|
||
|
||
def _external_dm_authorize_pair(
|
||
a_pid: str,
|
||
a_uid: str,
|
||
b_pid: str,
|
||
b_uid: str,
|
||
) -> tuple[str, dict]:
|
||
"""Thread-Zugriff: darf die Konversation zwischen a und b existieren (in eine Richtung)."""
|
||
try:
|
||
return _external_dm_authorize_send_direction(a_pid, a_uid, b_pid, b_uid)
|
||
except HTTPException:
|
||
pass
|
||
return _external_dm_authorize_send_direction(b_pid, b_uid, a_pid, a_uid)
|
||
|
||
|
||
def _external_dm_to_client_message(m: dict) -> dict:
|
||
"""Gleiches Kerndatenformat wie interne DMs fuer die Web-UI."""
|
||
ex0 = m.get("extras") if isinstance(m.get("extras"), dict) else {}
|
||
ex = dict(ex0)
|
||
mid = str(m.get("id") or "").strip()
|
||
s_pid = str(m.get("sender_practice_id") or "").strip()
|
||
s_uid = str(m.get("sender_user_id") or "").strip()
|
||
s_dn = str(m.get("sender_display_name") or "").strip()
|
||
r_pid = str(m.get("recipient_practice_id") or "").strip()
|
||
r_uid = str(m.get("recipient_user_id") or "").strip()
|
||
r_dn = str(m.get("recipient_display_name") or "").strip()
|
||
ex.setdefault("sender_user_id", s_uid)
|
||
ex.setdefault("recipient_user_id", r_uid)
|
||
ex.setdefault("sender_practice_id", s_pid)
|
||
ex.setdefault("recipient_practice_id", r_pid)
|
||
ex.setdefault("conversation_id", str(m.get("conversation_id") or ""))
|
||
ex.setdefault("external_link_id", str(m.get("external_link_id") or ""))
|
||
ex.setdefault("external_dm", True)
|
||
ex.setdefault("conv_type", "external_dm")
|
||
ts = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
return {
|
||
"id": mid,
|
||
"thread_id": mid,
|
||
"kommentar": str(m.get("kommentar") or ""),
|
||
"absender": (s_dn + " (Empfang)") if s_dn else " (Empfang)",
|
||
"zeitstempel": ts,
|
||
"empfangen": ts,
|
||
"status": str(m.get("status") or "offen"),
|
||
"extras": ex,
|
||
}
|
||
|
||
|
||
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")
|
||
ln_u = _allocate_unique_login_name(accounts, pid, name)
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": pid,
|
||
"display_name": name,
|
||
"email": admin_email,
|
||
"login_name": ln_u,
|
||
"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, pid_src = _practice_id_for_login(request, body)
|
||
if pid_src == "invite_invalid":
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Ungueltiger Einladungscode — bitte aktuellen Link aus der Hauptinstallation verwenden.",
|
||
)
|
||
if not raw or not password:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Login-Name 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
|
||
login_recovered_practice = False
|
||
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": (
|
||
"Diese E-Mail ist mehreren Benutzern zugeordnet. "
|
||
"Bitte melden Sie sich mit Ihrem Benutzernamen an."
|
||
),
|
||
"candidates": [
|
||
{
|
||
"display_name": (a.get("display_name") or ""),
|
||
"login_name": (a.get("login_name") or "").strip(),
|
||
}
|
||
for a in matches
|
||
],
|
||
},
|
||
)
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={
|
||
"code": "ambiguous_email",
|
||
"message": (
|
||
"Diese E-Mail ist mehreren Benutzern zugeordnet. "
|
||
"Bitte melden Sie sich mit Ihrem Benutzernamen an."
|
||
),
|
||
},
|
||
)
|
||
else:
|
||
matches, _via_login = _resolve_browser_login_matches(scoped, 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": (
|
||
"Diese Anmeldedaten sind in dieser Praxis nicht eindeutig. "
|
||
"Ein Administrator muss im Hauptfenster fuer die betroffenen Konten einen "
|
||
"eindeutigen Login-Namen festlegen. Bitte verwenden Sie danach diesen "
|
||
"Benutzernamen fuer die Anmeldung."
|
||
),
|
||
},
|
||
)
|
||
|
||
if not target and pid and not _is_likely_email(raw):
|
||
gmatches, _via_global = _resolve_browser_login_matches(
|
||
list(accounts.values()), raw,
|
||
)
|
||
if len(gmatches) == 1:
|
||
target = gmatches[0]
|
||
if pid_src != "invite":
|
||
login_recovered_practice = True
|
||
|
||
if not target:
|
||
raise HTTPException(
|
||
status_code=401,
|
||
detail="Benutzer nicht gefunden oder falsches Passwort",
|
||
)
|
||
tpid = (target.get("practice_id") or "").strip()
|
||
invite_will_clone = bool(pid_src == "invite" and pid and tpid and tpid != pid)
|
||
if pid and tpid != pid and not login_recovered_practice and not invite_will_clone:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=(
|
||
"Anmeldung passt nicht zur gewaehlten Praxis (Einladungscode / gespeicherte Praxis-ID). "
|
||
"Bitte den Einladungslink der Hauptinstallation erneut oeffnen."
|
||
),
|
||
)
|
||
if not pid or login_recovered_practice:
|
||
if not invite_will_clone:
|
||
pid = tpid
|
||
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",
|
||
)
|
||
|
||
did_invite_clone = False
|
||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
old_practice_before = ""
|
||
source_user_before = ""
|
||
if invite_will_clone:
|
||
old_practice_before = (target.get("practice_id") or "").strip()
|
||
source_user_before = (target.get("user_id") or "").strip()
|
||
if invite_will_clone:
|
||
_ensure_practice(pid)
|
||
_ensure_default_channels(pid)
|
||
target = _invite_join_clone_account(accounts, target, pid, now)
|
||
did_invite_clone = True
|
||
_log.info(
|
||
"AZA_EMPFANG_PRACTICE_JOIN_CLONE src_user=%s old_practice=%s new_practice=%s "
|
||
"clone_user=%s role=%s admin_assigned=%s invite_tail=%s",
|
||
(source_user_before or "")[:12],
|
||
(old_practice_before or "")[:16],
|
||
(pid or "")[:16],
|
||
(target.get("user_id") or "")[:12],
|
||
(target.get("role") or ""),
|
||
str(_account_has_practice_admin_privileges(target)).lower(),
|
||
_audit_invite_tail(body.get("invite_code")),
|
||
)
|
||
else:
|
||
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,
|
||
)
|
||
|
||
bind_src = (
|
||
"invite_code_join"
|
||
if did_invite_clone
|
||
else (
|
||
"invite_code"
|
||
if pid_src == "invite"
|
||
else (
|
||
"username_recovered_practice"
|
||
if login_recovered_practice
|
||
else (
|
||
"stored_practice_id"
|
||
if pid_src in ("body", "header", "query")
|
||
else "account"
|
||
)
|
||
)
|
||
)
|
||
)
|
||
result = {
|
||
"success": True,
|
||
"user_id": target["user_id"],
|
||
"role": target["role"],
|
||
"display_name": dn,
|
||
"practice_id": pid,
|
||
"practice_bind_source": bind_src,
|
||
}
|
||
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"
|
||
target_pid = _lookup_practice_id_by_invite(invite_code)
|
||
if not target_pid:
|
||
raise HTTPException(status_code=403, detail="Ungueltiger Einladungscode")
|
||
accounts = _load_accounts()
|
||
exists = any(
|
||
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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")
|
||
ln_assign = _preferred_unique_login_for_display(accounts, target_pid, name, "")
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": target_pid,
|
||
"display_name": name,
|
||
"email": email,
|
||
"login_name": ln_assign,
|
||
"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,
|
||
"practice_bind_source": "invite_code",
|
||
})
|
||
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", "")
|
||
s = _session_from_request(request)
|
||
if s:
|
||
try:
|
||
_presence_clear_user(
|
||
(s.get("practice_id") or "").strip(),
|
||
(s.get("user_id") or "").strip(),
|
||
)
|
||
except Exception:
|
||
pass
|
||
_delete_session(token)
|
||
resp = JSONResponse(content={"success": True})
|
||
resp.delete_cookie("aza_session")
|
||
return resp
|
||
|
||
|
||
@router.get("/auth/resolve_invite")
|
||
async def auth_resolve_invite(code: str = Query("")):
|
||
"""Loesst einen Chat-Einladungscode in practice_id auf (ohne Login). Fuer Browser-Start mit ?invite=."""
|
||
raw = (code or "").strip()
|
||
if not raw:
|
||
return JSONResponse(content={"valid": False})
|
||
pid = _lookup_practice_id_by_invite(raw)
|
||
if not pid:
|
||
return JSONResponse(
|
||
content={"valid": False, "detail": "Ungueltiger oder veralteter Einladungscode"},
|
||
)
|
||
practices = _load_practices()
|
||
pdata = practices.get(pid, {})
|
||
return JSONResponse(content={
|
||
"valid": True,
|
||
"practice_id": pid,
|
||
"practice_name": (pdata.get("name") or "").strip(),
|
||
"invite_code": (pdata.get("invite_code") or "").strip(),
|
||
})
|
||
|
||
|
||
# =====================================================================
|
||
# Externe Praxen (Praxis-zu-Praxis-Verbindungen)
|
||
# =====================================================================
|
||
#
|
||
# Datenmodell:
|
||
# data/empfang_practice_links.json = {"links": [{
|
||
# "id":"lnk_...",
|
||
# "source_practice_id":"prac_A",
|
||
# "target_practice_id":"prac_B",
|
||
# "source_practice_name":"...",
|
||
# "target_practice_name":"...",
|
||
# "status":"accepted"|"pending_outgoing"|"pending_incoming"|
|
||
# "rejected"|"removed",
|
||
# "created_by_user_id":"u_...",
|
||
# "created_at":"...Z",
|
||
# "updated_at":"...Z",
|
||
# "invite_code_used":"CHAT-..."
|
||
# }]}
|
||
#
|
||
# Semantik:
|
||
# - Eine externe Praxis-Verbindung wird ausschliesslich UEBER DEN CHAT-
|
||
# EINLADUNGSCODE der ZIELPRAXIS angelegt. Der Code gilt als
|
||
# beidseitige Genehmigung, weil ihn die Zielpraxis bewusst aus der
|
||
# Adminverwaltung exportiert hat. Status nach Anlegen: "accepted".
|
||
# - Eine externe Verbindung ANDERT NICHT die eigene practice_id und
|
||
# legt KEINE Benutzer in der Zielpraxis an. Rollen wie admin/mpa/arzt
|
||
# werden NICHT praxisuebergreifend uebernommen.
|
||
# - Beide Praxen sehen den Link in ihrer eigenen GET-Liste.
|
||
# - Auth: Browser-Cookie-Session oder Desktop mit X-API-Token +
|
||
# X-Practice-Id + X-AzA-Empfang-User-Id (wie Presence/Desktop-Shell).
|
||
|
||
_PRACTICE_LINKS_FILE = _DATA_DIR / "empfang_practice_links.json"
|
||
|
||
|
||
def _load_practice_links_store() -> dict:
|
||
data = _load_json(_PRACTICE_LINKS_FILE, {"links": []})
|
||
if not isinstance(data, dict):
|
||
return {"links": []}
|
||
links = data.get("links")
|
||
if not isinstance(links, list):
|
||
data["links"] = []
|
||
return data
|
||
|
||
|
||
def _save_practice_links_store(data: dict) -> None:
|
||
_save_json(_PRACTICE_LINKS_FILE, data)
|
||
|
||
|
||
def _generate_practice_link_id() -> str:
|
||
return f"lnk_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
||
|
||
|
||
def _practice_links_visible_to(pid: str) -> list:
|
||
"""Alle Links, in denen pid entweder source oder target ist und die
|
||
nicht "removed" sind (removed bleibt fuer Audit erhalten, ist aber UI-
|
||
seitig unsichtbar)."""
|
||
store = _load_practice_links_store()
|
||
out = []
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
if (link.get("status") or "") == "removed":
|
||
continue
|
||
if link.get("source_practice_id") == pid or link.get("target_practice_id") == pid:
|
||
out.append(link)
|
||
return out
|
||
|
||
|
||
def _serialize_practice_link_for(pid: str, link: dict) -> dict:
|
||
"""Fuer GET-Antworten: wir markieren explizit, ob pid in source oder
|
||
target steht, damit der Client schnell rendern kann ("eingehend"
|
||
vs. "ausgehend"). Wir geben absichtlich Display-Namen mit."""
|
||
is_outgoing = link.get("source_practice_id") == pid
|
||
peer_pid = link.get("target_practice_id") if is_outgoing else link.get("source_practice_id")
|
||
peer_name = link.get("target_practice_name") if is_outgoing else link.get("source_practice_name")
|
||
return {
|
||
"id": str(link.get("id") or ""),
|
||
"status": str(link.get("status") or ""),
|
||
"direction": "outgoing" if is_outgoing else "incoming",
|
||
"peer_practice_id": str(peer_pid or ""),
|
||
"peer_practice_name": str(peer_name or ""),
|
||
"created_at": str(link.get("created_at") or ""),
|
||
"updated_at": str(link.get("updated_at") or ""),
|
||
"created_by_user_id": str(link.get("created_by_user_id") or ""),
|
||
"last_message_at": str(link.get("last_message_at") or ""),
|
||
}
|
||
|
||
|
||
def _find_practice_link(link_id: str, pid: str) -> Optional[dict]:
|
||
"""Liefert den Roh-Link, wenn pid Teilhaber ist; sonst None."""
|
||
if not link_id or not pid:
|
||
return None
|
||
store = _load_practice_links_store()
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
if link.get("id") != link_id:
|
||
continue
|
||
if link.get("source_practice_id") == pid or link.get("target_practice_id") == pid:
|
||
return link
|
||
return None
|
||
|
||
|
||
def _practice_name_safe(pid: str) -> str:
|
||
if not pid:
|
||
return ""
|
||
try:
|
||
practices = _load_practices()
|
||
return str((practices.get(pid) or {}).get("name") or "").strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
@router.get("/external-practices")
|
||
async def empfang_external_practices_list(request: Request):
|
||
"""Liste der eigenen externen Praxis-Verbindungen."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
own_links = [
|
||
x for x in _practice_links_visible_to(pid)
|
||
if _link_contact_type(x) == _CONTACT_TYPE_PRACTICE
|
||
]
|
||
own_links.sort(key=lambda x: str(x.get("updated_at") or x.get("created_at") or ""), reverse=True)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_practices": [_serialize_practice_link_for(pid, link) for link in own_links],
|
||
})
|
||
|
||
|
||
@router.post("/external-practices/link-by-code")
|
||
async def empfang_external_practices_link_by_code(request: Request):
|
||
"""Verbindet die eigene Praxis mit einer fremden Praxis via Chat-
|
||
Einladungscode der Zielpraxis. Andert NICHT die eigene practice_id.
|
||
|
||
Body: {"code": "CHAT-...-..."}
|
||
|
||
Sicherheit:
|
||
- Code ist beidseitige Genehmigung (kommt aus B-Adminverwaltung).
|
||
- Selbst-Link (source==target) wird abgelehnt.
|
||
- Mehrfach-Linken wird idempotent gehandhabt (Update statt Duplikat).
|
||
"""
|
||
s = _require_session(request)
|
||
source_pid = (s.get("practice_id") or "").strip()
|
||
source_uid = (s.get("user_id") or "").strip()
|
||
if not source_pid or not source_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
raw_code = ""
|
||
if isinstance(body, dict):
|
||
raw_code = (body.get("code") or body.get("invite_code") or "").strip()
|
||
if not raw_code:
|
||
raise HTTPException(status_code=400, detail="code erforderlich")
|
||
|
||
target_pid = _lookup_practice_id_by_invite(raw_code)
|
||
if not target_pid:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Code ist keiner Praxis zugeordnet oder bereits abgelaufen.",
|
||
)
|
||
if target_pid == source_pid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Sie koennen Ihre eigene Praxis nicht als externen Kontakt hinzufuegen.",
|
||
)
|
||
|
||
practices = _load_practices()
|
||
src_name = str((practices.get(source_pid) or {}).get("name") or "").strip()
|
||
tgt_name = str((practices.get(target_pid) or {}).get("name") or "").strip()
|
||
|
||
store = _load_practice_links_store()
|
||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
|
||
# Suche existierenden Link (egal in welcher Richtung gespeichert).
|
||
existing = None
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
a = link.get("source_practice_id")
|
||
b = link.get("target_practice_id")
|
||
if {a, b} == {source_pid, target_pid} \
|
||
and _link_contact_type(link) == _CONTACT_TYPE_PRACTICE:
|
||
existing = link
|
||
break
|
||
|
||
if existing is not None:
|
||
existing["contact_type"] = _CONTACT_TYPE_PRACTICE
|
||
existing["status"] = "accepted"
|
||
existing["updated_at"] = now
|
||
if not existing.get("invite_code_used"):
|
||
existing["invite_code_used"] = raw_code
|
||
# Display-Namen aktualisieren (falls Umbenennung).
|
||
if existing.get("source_practice_id") == source_pid:
|
||
existing["source_practice_name"] = src_name
|
||
existing["target_practice_name"] = tgt_name
|
||
else:
|
||
existing["source_practice_name"] = tgt_name
|
||
existing["target_practice_name"] = src_name
|
||
_save_practice_links_store(store)
|
||
out = existing
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTLINK_UPDATED source=%s target=%s status=%s",
|
||
(source_pid or "")[:16], (target_pid or "")[:16], out["status"],
|
||
)
|
||
else:
|
||
out = {
|
||
"id": _generate_practice_link_id(),
|
||
"contact_type": _CONTACT_TYPE_PRACTICE,
|
||
"source_practice_id": source_pid,
|
||
"target_practice_id": target_pid,
|
||
"source_practice_name": src_name,
|
||
"target_practice_name": tgt_name,
|
||
"status": "accepted", # Code = beidseitige Genehmigung
|
||
"created_by_user_id": source_uid,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"invite_code_used": raw_code,
|
||
}
|
||
store.setdefault("links", []).append(out)
|
||
_save_practice_links_store(store)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTLINK_CREATED source=%s target=%s",
|
||
(source_pid or "")[:16], (target_pid or "")[:16],
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_practice": _serialize_practice_link_for(source_pid, out),
|
||
})
|
||
|
||
|
||
@router.post("/external-practices/{link_id}/accept")
|
||
async def empfang_external_practices_accept(link_id: str, request: Request):
|
||
"""Markiert eine eingehende externe Verbindung als accepted.
|
||
|
||
Erlaubt fuer Mitglieder der Zielpraxis (die der ankommenden Seite).
|
||
Idempotent: bereits accepted -> bleibt accepted.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
link = _find_practice_link(link_id, pid)
|
||
if not link or _link_contact_type(link) != _CONTACT_TYPE_PRACTICE:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
link["status"] = "accepted"
|
||
link["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
store = _load_practice_links_store()
|
||
for i, x in enumerate(store.get("links") or []):
|
||
if isinstance(x, dict) and x.get("id") == link_id:
|
||
store["links"][i] = link
|
||
break
|
||
_save_practice_links_store(store)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_practice": _serialize_practice_link_for(pid, link),
|
||
})
|
||
|
||
|
||
@router.post("/external-practices/{link_id}/reject")
|
||
async def empfang_external_practices_reject(link_id: str, request: Request):
|
||
"""Lehnt eine externe Verbindung ab. Sichtbar fuer beide Seiten als
|
||
'rejected'. Kein Datenverlust auf der Gegenseite."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
link = _find_practice_link(link_id, pid)
|
||
if not link or _link_contact_type(link) != _CONTACT_TYPE_PRACTICE:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
link["status"] = "rejected"
|
||
link["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
store = _load_practice_links_store()
|
||
for i, x in enumerate(store.get("links") or []):
|
||
if isinstance(x, dict) and x.get("id") == link_id:
|
||
store["links"][i] = link
|
||
break
|
||
_save_practice_links_store(store)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_practice": _serialize_practice_link_for(pid, link),
|
||
})
|
||
|
||
|
||
@router.delete("/external-practices/{link_id}")
|
||
async def empfang_external_practices_remove(link_id: str, request: Request):
|
||
"""Entfernt die externe Verbindung aus der eigenen Sicht (status=removed).
|
||
|
||
Die Gegenseite bleibt informiert (sie sieht den Link weiterhin, aber
|
||
mit status=removed -- so kann sie ggf. erneut anfragen ohne Spam).
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
link = _find_practice_link(link_id, pid)
|
||
if not link:
|
||
return JSONResponse(content={"success": True, "removed": 0})
|
||
if _link_contact_type(link) != _CONTACT_TYPE_PRACTICE:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Persoenliche externe Kontakte bitte ueber /external-contacts entfernen.",
|
||
)
|
||
link["status"] = "removed"
|
||
link["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
store = _load_practice_links_store()
|
||
for i, x in enumerate(store.get("links") or []):
|
||
if isinstance(x, dict) and x.get("id") == link_id:
|
||
store["links"][i] = link
|
||
break
|
||
_save_practice_links_store(store)
|
||
return JSONResponse(content={"success": True, "removed": 1})
|
||
|
||
|
||
# =====================================================================
|
||
# Persoenliche Benutzer-Notizen (pro practice_id + user_id)
|
||
# =====================================================================
|
||
#
|
||
# Strikte Trennung von Aufgaben, Briefen, Chatnachrichten und externen
|
||
# Kontakten:
|
||
# - Notizen sind PERSOENLICH des angemeldeten Benutzers.
|
||
# - Speicherung in data/empfang_user_notes.json:
|
||
# {"notes":[{
|
||
# "id":"note_...","practice_id":"prac_...","user_id":"u_...",
|
||
# "title":"...optional","body":"...","pinned":false,
|
||
# "client_id":"...optional","source":"manual",
|
||
# "status":"active"|"deleted",
|
||
# "created_at":"...Z","updated_at":"...Z","deleted_at":"...Z"|""
|
||
# }]}
|
||
# - Endpunkte verwenden ausschliesslich practice_id + user_id aus
|
||
# der serverseitigen Session. Body/Header-Felder zur User- oder
|
||
# Praxis-Identitaet werden ignoriert.
|
||
# - Soft-Delete: status=deleted, deleted_at gesetzt; GET filtert
|
||
# gelosechte Notizen aus.
|
||
# - Audit-Log: nur id und Anzahl, niemals Inhalt der Notiz.
|
||
|
||
_USER_NOTES_FILE = _DATA_DIR / "empfang_user_notes.json"
|
||
_NOTES_MAX_TITLE = 200
|
||
_NOTES_MAX_BODY = 8000
|
||
_NOTES_MAX_PER_USER = 500
|
||
|
||
|
||
def _load_notes_store() -> dict:
|
||
data = _load_json(_USER_NOTES_FILE, {"notes": []})
|
||
if not isinstance(data, dict):
|
||
return {"notes": []}
|
||
if not isinstance(data.get("notes"), list):
|
||
data["notes"] = []
|
||
return data
|
||
|
||
|
||
def _save_notes_store(data: dict) -> None:
|
||
_save_json(_USER_NOTES_FILE, data)
|
||
|
||
|
||
def _generate_note_id() -> str:
|
||
return f"note_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
||
|
||
|
||
def _public_note(note: dict) -> dict:
|
||
return {
|
||
"id": str(note.get("id") or ""),
|
||
"title": str(note.get("title") or ""),
|
||
"body": str(note.get("body") or ""),
|
||
"pinned": bool(note.get("pinned")),
|
||
"source": str(note.get("source") or "manual"),
|
||
"created_at": str(note.get("created_at") or ""),
|
||
"updated_at": str(note.get("updated_at") or ""),
|
||
}
|
||
|
||
|
||
def _user_notes(pid: str, uid: str) -> list:
|
||
"""Aktive Notizen eines Benutzers in seiner Praxis."""
|
||
store = _load_notes_store()
|
||
out = []
|
||
for n in store.get("notes") or []:
|
||
if not isinstance(n, dict):
|
||
continue
|
||
if (n.get("status") or "active") == "deleted":
|
||
continue
|
||
if n.get("practice_id") != pid:
|
||
continue
|
||
if n.get("user_id") != uid:
|
||
continue
|
||
out.append(n)
|
||
return out
|
||
|
||
|
||
@router.get("/notes")
|
||
async def empfang_notes_list(request: Request):
|
||
"""Liste der eigenen aktiven Notizen, sortiert: gepinnte zuerst, dann
|
||
nach updated_at desc (Fallback created_at)."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
notes = _user_notes(pid, uid)
|
||
notes.sort(
|
||
key=lambda n: (
|
||
0 if n.get("pinned") else 1,
|
||
str(n.get("updated_at") or n.get("created_at") or ""),
|
||
),
|
||
reverse=False,
|
||
)
|
||
# `reverse=False` mit Tupel (pinned-Schluessel, ts) waere falsch sortiert
|
||
# bei der Zeit-Komponente; daher explizit nachsortieren:
|
||
notes.sort(key=lambda n: str(n.get("updated_at") or n.get("created_at") or ""), reverse=True)
|
||
notes.sort(key=lambda n: 0 if n.get("pinned") else 1)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"notes": [_public_note(n) for n in notes],
|
||
})
|
||
|
||
|
||
@router.post("/notes")
|
||
async def empfang_notes_create(request: Request):
|
||
"""Erstellt eine neue Notiz fuer (eigene practice_id, eigene user_id).
|
||
|
||
Body: {"title": "...", "body": "...", "client_id": "...", "pinned": false}
|
||
`practice_id` und `user_id` werden aus der Session entnommen.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
if not isinstance(body, dict):
|
||
body = {}
|
||
title = _clip_text(body.get("title", ""), _NOTES_MAX_TITLE).strip()
|
||
content = _clip_text(body.get("body", ""), _NOTES_MAX_BODY)
|
||
if not content.strip() and not title:
|
||
raise HTTPException(status_code=400, detail="body oder title erforderlich")
|
||
client_id = str(body.get("client_id") or "").strip()
|
||
if client_id and not client_id.startswith("note_"):
|
||
client_id = ""
|
||
pinned = bool(body.get("pinned"))
|
||
|
||
store = _load_notes_store()
|
||
existing_for_user = [
|
||
n for n in store.get("notes") or []
|
||
if isinstance(n, dict)
|
||
and n.get("practice_id") == pid
|
||
and n.get("user_id") == uid
|
||
and (n.get("status") or "active") == "active"
|
||
]
|
||
if len(existing_for_user) >= _NOTES_MAX_PER_USER:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Maximal {_NOTES_MAX_PER_USER} Notizen pro Benutzer.",
|
||
)
|
||
|
||
if client_id and any(
|
||
isinstance(n, dict) and n.get("id") == client_id for n in store.get("notes") or []
|
||
):
|
||
client_id = ""
|
||
note_id = client_id or _generate_note_id()
|
||
|
||
now = _now_z()
|
||
note = {
|
||
"id": note_id,
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
"title": title,
|
||
"body": content,
|
||
"pinned": pinned,
|
||
"source": "manual",
|
||
"status": "active",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"deleted_at": "",
|
||
}
|
||
store.setdefault("notes", []).append(note)
|
||
_save_notes_store(store)
|
||
_log.info(
|
||
"AZA_EMPFANG_NOTE_CREATED practice=%s user=%s note=%s",
|
||
(pid or "")[:16], (uid or "")[:16], (note_id or "")[:18],
|
||
)
|
||
return JSONResponse(content={"success": True, "note": _public_note(note)})
|
||
|
||
|
||
@router.patch("/notes/{note_id}")
|
||
async def empfang_notes_update(note_id: str, request: Request):
|
||
"""Aktualisiert title/body/pinned einer eigenen Notiz."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
if not isinstance(body, dict):
|
||
body = {}
|
||
|
||
store = _load_notes_store()
|
||
target = None
|
||
target_idx = -1
|
||
for i, n in enumerate(store.get("notes") or []):
|
||
if not isinstance(n, dict):
|
||
continue
|
||
if n.get("id") != note_id:
|
||
continue
|
||
if n.get("practice_id") != pid or n.get("user_id") != uid:
|
||
continue
|
||
if (n.get("status") or "active") == "deleted":
|
||
continue
|
||
target = n
|
||
target_idx = i
|
||
break
|
||
if target is None:
|
||
raise HTTPException(status_code=404, detail="Notiz nicht gefunden")
|
||
|
||
changed = False
|
||
if "title" in body:
|
||
target["title"] = _clip_text(body.get("title", ""), _NOTES_MAX_TITLE).strip()
|
||
changed = True
|
||
if "body" in body:
|
||
content = _clip_text(body.get("body", ""), _NOTES_MAX_BODY)
|
||
target["body"] = content
|
||
changed = True
|
||
if "pinned" in body:
|
||
target["pinned"] = bool(body.get("pinned"))
|
||
changed = True
|
||
if changed:
|
||
target["updated_at"] = _now_z()
|
||
store["notes"][target_idx] = target
|
||
_save_notes_store(store)
|
||
_log.info(
|
||
"AZA_EMPFANG_NOTE_UPDATED practice=%s user=%s note=%s",
|
||
(pid or "")[:16], (uid or "")[:16], (note_id or "")[:18],
|
||
)
|
||
return JSONResponse(content={"success": True, "note": _public_note(target)})
|
||
|
||
|
||
@router.delete("/notes/{note_id}")
|
||
async def empfang_notes_delete(note_id: str, request: Request):
|
||
"""Soft-Delete einer eigenen Notiz."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
store = _load_notes_store()
|
||
found = False
|
||
for i, n in enumerate(store.get("notes") or []):
|
||
if not isinstance(n, dict):
|
||
continue
|
||
if n.get("id") != note_id:
|
||
continue
|
||
if n.get("practice_id") != pid or n.get("user_id") != uid:
|
||
continue
|
||
if (n.get("status") or "active") == "deleted":
|
||
return JSONResponse(content={"success": True, "removed": 0})
|
||
n["status"] = "deleted"
|
||
n["deleted_at"] = _now_z()
|
||
n["updated_at"] = n["deleted_at"]
|
||
store["notes"][i] = n
|
||
found = True
|
||
break
|
||
if not found:
|
||
raise HTTPException(status_code=404, detail="Notiz nicht gefunden")
|
||
_save_notes_store(store)
|
||
_log.info(
|
||
"AZA_EMPFANG_NOTE_DELETED practice=%s user=%s note=%s",
|
||
(pid or "")[:16], (uid or "")[:16], (note_id or "")[:18],
|
||
)
|
||
return JSONResponse(content={"success": True, "removed": 1})
|
||
|
||
|
||
# =====================================================================
|
||
# Externe Kontakte (Praxis-Verbindungen UND einzelne externe Personen)
|
||
# =====================================================================
|
||
#
|
||
# Aufbauend auf dem bestehenden practice-links-Store werden Eintraege um
|
||
# das Feld ``contact_type`` ergaenzt:
|
||
# - "external_practice": Praxis-zu-Praxis-Verbindung
|
||
# (Code = beidseitige Genehmigung -> accepted)
|
||
# - "external_person" (Alias API: personal_external_contact):
|
||
# Persoenlicher 1:1-Kontakt Praxisgrenzen ueber
|
||
# genau zwei Benutzer. Keine Praxisliste der
|
||
# Gegenseite; keine automatischen Neukontakte.
|
||
#
|
||
# Bestehende Eintraege ohne contact_type werden weiterhin als
|
||
# external_practice interpretiert (Backward-Compat).
|
||
#
|
||
# Sicherheitsmodell (external_person):
|
||
# - Person bleibt Benutzer ihrer Quellpraxis — kein locals.json-Eintrag
|
||
# in der Zielpraxis, keine Rollenuebernahme.
|
||
# - Sichtbarkeit: nur source_user_id und target_user_id (Legacy ohne
|
||
# target_user_id: siehe _person_link_visible_to_user).
|
||
# - Annahme/Ablehnung: Zielbenutzer (target_user_id), nicht „alle Admins“.
|
||
# Legacy-Anfragen ohne target_user_id: weiterhin Admin der Zielpraxis.
|
||
# - Entfernen: die beiden Parteien (bzw. Legacy-Admin); Praxisbeitritt
|
||
# entsteht dadurch nicht.
|
||
|
||
_CONTACT_TYPE_PRACTICE = "external_practice"
|
||
_CONTACT_TYPE_PERSON = "external_person"
|
||
# API-Alias (Produktsprache); wird beim Lesen normalisiert, intern weiterhin
|
||
# ``external_person`` als JSON-Wert gespeichert.
|
||
_CONTACT_TYPE_PERSON_ALIAS = "personal_external_contact"
|
||
_VALID_CONTACT_TYPES = {
|
||
_CONTACT_TYPE_PRACTICE,
|
||
_CONTACT_TYPE_PERSON,
|
||
_CONTACT_TYPE_PERSON_ALIAS,
|
||
}
|
||
_VALID_LINK_STATUS = {
|
||
"pending_outgoing", "pending_incoming",
|
||
"accepted", "rejected", "blocked", "removed",
|
||
}
|
||
|
||
|
||
def _link_contact_type(link: dict) -> str:
|
||
"""Liest contact_type robust; Defaults auf external_practice."""
|
||
if not isinstance(link, dict):
|
||
return _CONTACT_TYPE_PRACTICE
|
||
t = (link.get("contact_type") or "").strip()
|
||
if t == _CONTACT_TYPE_PERSON_ALIAS:
|
||
return _CONTACT_TYPE_PERSON
|
||
if t in (_CONTACT_TYPE_PRACTICE, _CONTACT_TYPE_PERSON):
|
||
return t
|
||
return _CONTACT_TYPE_PRACTICE
|
||
|
||
|
||
def _effective_person_target_user_id(link: dict) -> str:
|
||
"""Zielbenutzer einer persoenlichen externen Verbindung (1:1).
|
||
|
||
Neu: explizites ``target_user_id``. Legacy: bei ``accepted`` ohne Ziel-ID
|
||
wird ``approved_by_user_id`` als Gegenpart angenommen (Admin hat frueher
|
||
angenommen).
|
||
"""
|
||
if not isinstance(link, dict):
|
||
return ""
|
||
tu = (link.get("target_user_id") or "").strip()
|
||
if tu:
|
||
return tu
|
||
st = str(link.get("status") or "").strip().lower()
|
||
if st == "accepted":
|
||
return (
|
||
(link.get("approved_by_user_id") or link.get("accepted_by_user_id") or "")
|
||
.strip()
|
||
)
|
||
return ""
|
||
|
||
|
||
def _person_link_visible_to_user(
|
||
link: dict, pid: str, uid: str, session_is_admin: bool,
|
||
) -> bool:
|
||
"""Sichtbarkeit strikt 1:1; Admins sehen keine neuen Personenanfragen, ausser Legacy."""
|
||
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
|
||
return True
|
||
pid = (pid or "").strip()
|
||
uid = (uid or "").strip()
|
||
sp = (link.get("source_practice_id") or "").strip()
|
||
tp = (link.get("target_practice_id") or "").strip()
|
||
su = (link.get("source_user_id") or "").strip()
|
||
if pid == sp and su and uid == su:
|
||
return True
|
||
if pid != tp:
|
||
return False
|
||
tu_eff = (link.get("target_user_id") or "").strip()
|
||
st = str(link.get("status") or "").strip().lower()
|
||
if tu_eff:
|
||
return uid == tu_eff
|
||
# Legacy: keine explizite Zielperson — eingehend nur fuer Admins der Zielpraxis
|
||
if st in ("pending_outgoing", "pending_incoming", "pending"):
|
||
return bool(session_is_admin)
|
||
if st == "accepted":
|
||
ap = (link.get("approved_by_user_id") or "").strip()
|
||
return bool(ap and uid == ap)
|
||
return False
|
||
|
||
|
||
def _practice_links_visible_for_user(
|
||
pid: str, uid: str, session_is_admin: bool,
|
||
) -> list:
|
||
"""Wie _practice_links_visible_to, aber Persoenliche Kontakte nur fuer die zwei Parteien."""
|
||
pid = (pid or "").strip()
|
||
uid = (uid or "").strip()
|
||
store = _load_practice_links_store()
|
||
out: list = []
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
if (link.get("status") or "") == "removed":
|
||
continue
|
||
if link.get("source_practice_id") != pid and link.get("target_practice_id") != pid:
|
||
continue
|
||
if _link_contact_type(link) == _CONTACT_TYPE_PERSON:
|
||
if not _person_link_visible_to_user(link, pid, uid, session_is_admin):
|
||
continue
|
||
out.append(link)
|
||
return out
|
||
|
||
|
||
def _find_practice_link_for_user(
|
||
link_id: str, pid: str, uid: str, session_is_admin: bool,
|
||
) -> Optional[dict]:
|
||
link = _find_practice_link(link_id, pid)
|
||
if not link:
|
||
return None
|
||
if _link_contact_type(link) == _CONTACT_TYPE_PERSON:
|
||
if not _person_link_visible_to_user(link, pid, uid, session_is_admin):
|
||
return None
|
||
return link
|
||
|
||
|
||
def _person_link_may_remove(
|
||
link: dict, pid: str, uid: str, session_is_admin: bool,
|
||
) -> bool:
|
||
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
|
||
return True
|
||
sp = (link.get("source_practice_id") or "").strip()
|
||
tp = (link.get("target_practice_id") or "").strip()
|
||
su = (link.get("source_user_id") or "").strip()
|
||
tu = (link.get("target_user_id") or "").strip()
|
||
if pid == sp and su and uid == su:
|
||
return True
|
||
if pid == tp and tu and uid == tu:
|
||
return True
|
||
if pid == tp and (not tu) and session_is_admin:
|
||
return True
|
||
ap = (link.get("approved_by_user_id") or "").strip()
|
||
if pid == tp and (not tu) and ap and uid == ap:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _person_may_moderate_incoming(link: dict, uid: str, session_is_admin: bool) -> bool:
|
||
"""accept/reject/block: Zielbenutzer oder Legacy-Admin."""
|
||
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
|
||
return session_is_admin
|
||
uid = (uid or "").strip()
|
||
if link.get("target_practice_id") and (link.get("source_practice_id") == link.get("target_practice_id")):
|
||
return False
|
||
tu = (link.get("target_user_id") or "").strip()
|
||
if tu:
|
||
return uid == tu
|
||
return session_is_admin
|
||
|
||
|
||
def _serialize_external_contact_for(pid: str, uid: str, link: dict) -> dict:
|
||
"""Generischer Serializer fuer beide Kontaktarten.
|
||
|
||
Dreht 'direction' aus pid-Sicht (outgoing wenn source==pid, sonst incoming),
|
||
und liefert je nach contact_type passende 'peer_*'-Felder:
|
||
- external_practice: peer = Gegenpraxis
|
||
- external_person: bei pid==target: peer_user_display = Anzeige der
|
||
Person; bei pid==source: peer_user_display ist
|
||
entweder leer (vor accept) oder ein evtl. spaeter
|
||
eingetragener Zielname; peer_practice_name immer
|
||
die Gegenpraxis.
|
||
"""
|
||
is_outgoing = link.get("source_practice_id") == pid
|
||
ctype = _link_contact_type(link)
|
||
peer_pid = link.get("target_practice_id") if is_outgoing else link.get("source_practice_id")
|
||
peer_pname = link.get("target_practice_name") if is_outgoing else link.get("source_practice_name")
|
||
own_pname = link.get("source_practice_name") if is_outgoing else link.get("target_practice_name")
|
||
peer_user_display = ""
|
||
if ctype == _CONTACT_TYPE_PERSON:
|
||
if is_outgoing:
|
||
peer_user_display = str(link.get("target_display_name") or "").strip()
|
||
if not peer_user_display:
|
||
tuid = (link.get("target_user_id") or "").strip()
|
||
if tuid:
|
||
acc_t = (_load_accounts().get(tuid) or {})
|
||
if isinstance(acc_t, dict) and (acc_t.get("practice_id") or "").strip() == str(
|
||
link.get("target_practice_id") or "",
|
||
).strip():
|
||
peer_user_display = str(acc_t.get("display_name") or "").strip()
|
||
else:
|
||
peer_user_display = str(link.get("source_display_name") or "").strip()
|
||
|
||
out = {
|
||
"id": str(link.get("id") or ""),
|
||
"contact_type": ctype,
|
||
"status": str(link.get("status") or ""),
|
||
"direction": "outgoing" if is_outgoing else "incoming",
|
||
"peer_practice_id": str(peer_pid or ""),
|
||
"peer_practice_name": str(peer_pname or ""),
|
||
"peer_user_display": peer_user_display,
|
||
"own_practice_name": str(own_pname or ""),
|
||
"note": str(link.get("note") or "")[:500],
|
||
"created_at": str(link.get("created_at") or ""),
|
||
"updated_at": str(link.get("updated_at") or ""),
|
||
"requested_by_user_id": str(link.get("requested_by_user_id") or link.get("created_by_user_id") or ""),
|
||
"approved_by_user_id": str(link.get("approved_by_user_id") or ""),
|
||
"accepted_by_user_id": str(link.get("accepted_by_user_id") or link.get("approved_by_user_id") or ""),
|
||
"target_user_id": str(link.get("target_user_id") or ""),
|
||
"last_message_at": str(link.get("last_message_at") or ""),
|
||
}
|
||
# Nur bei akzeptierter Personen-Verbindung: Gegen-user_id fuer 1:1-Chat.
|
||
if (
|
||
ctype == _CONTACT_TYPE_PERSON
|
||
and str(link.get("status") or "").strip().lower() == "accepted"
|
||
):
|
||
sp = (link.get("source_practice_id") or "").strip()
|
||
tp = (link.get("target_practice_id") or "").strip()
|
||
su = (link.get("source_user_id") or "").strip()
|
||
tu_eff = _effective_person_target_user_id(link)
|
||
if pid == tp and su:
|
||
out["peer_user_id"] = su
|
||
elif pid == sp and tu_eff:
|
||
out["peer_user_id"] = tu_eff
|
||
else:
|
||
out["peer_user_id"] = ""
|
||
return out
|
||
|
||
|
||
def _is_admin_session(s: dict) -> bool:
|
||
try:
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not uid:
|
||
return False
|
||
accounts = _load_accounts()
|
||
acc = accounts.get(uid)
|
||
return _account_has_practice_admin_privileges(acc)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _now_z() -> str:
|
||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
|
||
|
||
def _persist_link_update(link_id: str, updated: dict) -> None:
|
||
store = _load_practice_links_store()
|
||
for i, x in enumerate(store.get("links") or []):
|
||
if isinstance(x, dict) and x.get("id") == link_id:
|
||
store["links"][i] = updated
|
||
break
|
||
else:
|
||
store.setdefault("links", []).append(updated)
|
||
_save_practice_links_store(store)
|
||
|
||
|
||
@router.get("/external-contacts")
|
||
async def empfang_external_contacts_list(
|
||
request: Request,
|
||
contact_type: Optional[str] = Query(None),
|
||
):
|
||
"""Liste aller externen Kontakte (beide Typen) aus eigener Praxis-Sicht.
|
||
|
||
Optional ``?contact_type=external_practice`` oder ``external_person``.
|
||
Eintraege mit status=removed werden ausgeblendet.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
is_adm = _is_admin_session(s)
|
||
own_links = _practice_links_visible_for_user(pid, uid, is_adm)
|
||
if contact_type:
|
||
ct = contact_type.strip().lower()
|
||
if ct == _CONTACT_TYPE_PERSON_ALIAS:
|
||
ct = _CONTACT_TYPE_PERSON
|
||
if ct in (_CONTACT_TYPE_PRACTICE, _CONTACT_TYPE_PERSON):
|
||
own_links = [x for x in own_links if _link_contact_type(x) == ct]
|
||
own_links.sort(
|
||
key=lambda x: str(x.get("updated_at") or x.get("created_at") or ""),
|
||
reverse=True,
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_contacts": [
|
||
_serialize_external_contact_for(pid, uid, x) for x in own_links
|
||
],
|
||
})
|
||
|
||
|
||
def _external_peer_practice_user_rows(peer_pid: str) -> list[dict]:
|
||
"""Aktuelle Benutzer der Peer-Praxis aus der Account-DB (nicht als Snapshot am Link).
|
||
|
||
Nur sendbare Konten (_account_is_sendable): keine deaktivierten/geloeschten.
|
||
"""
|
||
peer_pid = (peer_pid or "").strip()
|
||
out: list[dict] = []
|
||
if not peer_pid:
|
||
return out
|
||
accounts = _load_accounts()
|
||
for a in accounts.values():
|
||
if not isinstance(a, dict):
|
||
continue
|
||
if (a.get("practice_id") or "").strip() != peer_pid:
|
||
continue
|
||
if not _account_is_sendable(a):
|
||
continue
|
||
uid = str(a.get("user_id") or "").strip()
|
||
if not uid:
|
||
continue
|
||
acc_st = str(a.get("status") or "active").strip().lower()
|
||
out.append({
|
||
"user_id": uid,
|
||
"display_name": str(a.get("display_name") or "").strip() or uid[:12],
|
||
"role": str(a.get("role") or "").strip(),
|
||
"status": acc_st or "active",
|
||
"external_practice_id": peer_pid,
|
||
})
|
||
out.sort(key=lambda x: (
|
||
(x.get("display_name") or "").strip().lower(),
|
||
x.get("user_id") or "",
|
||
))
|
||
return out
|
||
|
||
|
||
@router.get("/external-contacts/{link_id}/peer-users")
|
||
async def empfang_external_contact_peer_users(link_id: str, request: Request):
|
||
"""Minimaldaten der Gegenstelle fuer eine **accepted** Verbindung.
|
||
|
||
- external_practice: **immer live** alle sendbaren Benutzer der Gegenpraxis
|
||
(aktueller Stand aus der Account-DB; kein Snapshot am Link, kein Code neu).
|
||
- external_person: auf **beiden** Seiten genau **ein** Benutzer (1:1), nie
|
||
die gesamte Gegenpraxis-Liste.
|
||
|
||
Keine E-Mail, keine Tokens. Keine Uebernahme von Rollen in die andere Praxis.
|
||
"""
|
||
s = _session_or_shell_identity(request)
|
||
my_pid = (s.get("practice_id") or "").strip()
|
||
my_uid = (s.get("user_id") or "").strip()
|
||
if not my_pid or not my_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
is_adm = _is_admin_session(s)
|
||
link = _find_practice_link_for_user(link_id, my_pid, my_uid, is_adm)
|
||
if not link:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
st = str(link.get("status") or "").strip().lower()
|
||
if st != "accepted":
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Verbindung nicht freigegeben",
|
||
)
|
||
|
||
ctype = _link_contact_type(link)
|
||
peer_pid = ""
|
||
out: list[dict] = []
|
||
|
||
if ctype == _CONTACT_TYPE_PRACTICE:
|
||
is_outgoing = link.get("source_practice_id") == my_pid
|
||
peer_pid = (
|
||
link.get("target_practice_id") if is_outgoing else link.get("source_practice_id")
|
||
)
|
||
peer_pid = str(peer_pid or "").strip()
|
||
if not peer_pid or peer_pid == my_pid:
|
||
raise HTTPException(status_code=400, detail="Peer-Praxis ungueltig")
|
||
out = _external_peer_practice_user_rows(peer_pid)
|
||
elif ctype == _CONTACT_TYPE_PERSON:
|
||
sp = (link.get("source_practice_id") or "").strip()
|
||
tp = (link.get("target_practice_id") or "").strip()
|
||
su = (link.get("source_user_id") or "").strip()
|
||
tu_eff = _effective_person_target_user_id(link)
|
||
if not sp or not tp or not su or not tu_eff:
|
||
raise HTTPException(status_code=400, detail="Verbindung unvollstaendig")
|
||
if my_pid == tp:
|
||
peer_pid = sp
|
||
acc = _account_record_for_practice(su, sp)
|
||
if acc and _account_is_sendable(acc):
|
||
acc_st = str(acc.get("status") or "active").strip().lower()
|
||
out = [{
|
||
"user_id": su,
|
||
"display_name": str(acc.get("display_name") or "").strip() or su[:12],
|
||
"role": str(acc.get("role") or "").strip(),
|
||
"status": acc_st or "active",
|
||
"external_practice_id": sp,
|
||
}]
|
||
elif my_pid == sp:
|
||
if my_uid != su:
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||
peer_pid = tp
|
||
acc = _account_record_for_practice(tu_eff, tp)
|
||
if acc and _account_is_sendable(acc):
|
||
acc_st = str(acc.get("status") or "active").strip().lower()
|
||
out = [{
|
||
"user_id": tu_eff,
|
||
"display_name": str(acc.get("display_name") or "").strip() or tu_eff[:12],
|
||
"role": str(acc.get("role") or "").strip(),
|
||
"status": acc_st or "active",
|
||
"external_practice_id": tp,
|
||
}]
|
||
else:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
else:
|
||
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"link_id": link_id,
|
||
"external_practice_id": peer_pid,
|
||
"users": out,
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Externe DM: Lesestatus (serverseitig, mehrere Geraete)
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def _load_external_dm_reads_store() -> dict:
|
||
data = _load_json(_EXTERNAL_DM_READS_FILE, {"by_user": {}})
|
||
if not isinstance(data.get("by_user"), dict):
|
||
data["by_user"] = {}
|
||
return data
|
||
|
||
|
||
def _save_external_dm_reads_store(data: dict) -> None:
|
||
_save_json(_EXTERNAL_DM_READS_FILE, data)
|
||
|
||
|
||
def _external_dm_reads_actor_key(pid: str, uid: str) -> str:
|
||
return f"{(pid or '').strip()}|{(uid or '').strip()}"
|
||
|
||
|
||
def _external_dm_get_last_read_iso(pid: str, uid: str, conv_id: str) -> str:
|
||
store = _load_external_dm_reads_store()
|
||
d = (store.get("by_user") or {}).get(_external_dm_reads_actor_key(pid, uid)) or {}
|
||
return str(d.get((conv_id or "").strip()) or "").strip()
|
||
|
||
|
||
def _external_dm_set_last_read_iso(pid: str, uid: str, conv_id: str, iso: str) -> None:
|
||
if not pid or not uid or not conv_id or not iso:
|
||
return
|
||
store = _load_external_dm_reads_store()
|
||
bucket = store.setdefault("by_user", {})
|
||
key = _external_dm_reads_actor_key(pid, uid)
|
||
inner = bucket.setdefault(key, {})
|
||
inner[conv_id] = iso
|
||
bucket[key] = inner
|
||
store["by_user"] = bucket
|
||
_save_external_dm_reads_store(store)
|
||
|
||
|
||
def _external_dm_unread_aggregate(my_pid: str, my_uid: str) -> tuple[int, list[dict]]:
|
||
grouped: dict[str, list[dict]] = defaultdict(list)
|
||
for m in _load_external_dm_messages():
|
||
if not isinstance(m, dict) or str(m.get("conv_type") or "") != "external_dm":
|
||
continue
|
||
cid = str(m.get("conversation_id") or "").strip()
|
||
if not cid:
|
||
continue
|
||
sp = str(m.get("sender_practice_id") or "").strip()
|
||
su = str(m.get("sender_user_id") or "").strip()
|
||
rp = str(m.get("recipient_practice_id") or "").strip()
|
||
ru = str(m.get("recipient_user_id") or "").strip()
|
||
if (sp == my_pid and su == my_uid) or (rp == my_pid and ru == my_uid):
|
||
pass
|
||
else:
|
||
continue
|
||
peer_p = rp if sp == my_pid and su == my_uid else sp
|
||
peer_u = ru if sp == my_pid and su == my_uid else su
|
||
try:
|
||
_external_dm_authorize_pair(my_pid, my_uid, peer_p, peer_u)
|
||
except HTTPException:
|
||
continue
|
||
grouped[cid].append(m)
|
||
|
||
out: list[dict] = []
|
||
total = 0
|
||
for cid, msgs in grouped.items():
|
||
if not msgs:
|
||
continue
|
||
m0 = msgs[0]
|
||
sp = str(m0.get("sender_practice_id") or "").strip()
|
||
su = str(m0.get("sender_user_id") or "").strip()
|
||
rp = str(m0.get("recipient_practice_id") or "").strip()
|
||
ru = str(m0.get("recipient_user_id") or "").strip()
|
||
if rp == my_pid and ru == my_uid:
|
||
peer_p, peer_u = sp, su
|
||
else:
|
||
peer_p, peer_u = rp, ru
|
||
|
||
lr_iso = _external_dm_get_last_read_iso(my_pid, my_uid, cid)
|
||
lr_ts = _parse_msg_instant_utc_ts(lr_iso)
|
||
last_ts = 0.0
|
||
last_iso = ""
|
||
unread = 0
|
||
peer_dn = ""
|
||
for m in msgs:
|
||
ts_raw = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
ts_val = _parse_msg_instant_utc_ts(ts_raw)
|
||
if ts_val >= last_ts:
|
||
last_ts = ts_val
|
||
last_iso = ts_raw
|
||
snd = str(m.get("sender_user_id") or "").strip()
|
||
if snd and snd != my_uid and ts_val > lr_ts:
|
||
unread += 1
|
||
if not peer_dn:
|
||
acc = _account_record_for_practice(peer_u, peer_p) or {}
|
||
peer_dn = str(acc.get("display_name") or "").strip()
|
||
if not peer_dn:
|
||
for m in msgs:
|
||
if str(m.get("sender_user_id") or "").strip() == peer_u:
|
||
peer_dn = str(m.get("sender_display_name") or "").strip()
|
||
break
|
||
if str(m.get("recipient_user_id") or "").strip() == peer_u:
|
||
peer_dn = str(m.get("recipient_display_name") or "").strip()
|
||
break
|
||
if unread > 0:
|
||
total += unread
|
||
out.append({
|
||
"conversation_id": cid,
|
||
"peer_practice_id": peer_p,
|
||
"peer_practice_name": _practice_name_safe(peer_p),
|
||
"peer_user_id": peer_u,
|
||
"peer_display_name": peer_dn or peer_u[:12],
|
||
"last_message_at": last_iso,
|
||
"unread_count": unread,
|
||
})
|
||
out.sort(key=lambda x: _parse_msg_instant_utc_ts(str(x.get("last_message_at") or "")), reverse=True)
|
||
return total, out
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Chat-Anhaenge (Datei, Auth beim Abruf)
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def _ensure_attachments_dir() -> None:
|
||
_ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def _load_attachment_meta_store() -> dict:
|
||
data = _load_json(_ATTACHMENTS_META_FILE, {"attachments": {}})
|
||
if not isinstance(data.get("attachments"), dict):
|
||
data["attachments"] = {}
|
||
return data
|
||
|
||
|
||
def _save_attachment_meta_store(data: dict) -> None:
|
||
_save_json(_ATTACHMENTS_META_FILE, data)
|
||
|
||
|
||
def _generate_attachment_id() -> str:
|
||
return f"att_{uuid.uuid4().hex[:16]}"
|
||
|
||
|
||
def _normalize_upload_mime(raw: str, filename: str) -> str:
|
||
s = (raw or "").split(";", 1)[0].strip().lower()
|
||
if s in _ATTACHMENT_ALLOWED_MIME:
|
||
return s
|
||
fn = (filename or "").lower()
|
||
if fn.endswith(".png"):
|
||
return "image/png"
|
||
if fn.endswith(".jpg") or fn.endswith(".jpeg"):
|
||
return "image/jpeg"
|
||
if fn.endswith(".webp"):
|
||
return "image/webp"
|
||
return ""
|
||
|
||
|
||
def _safe_attachment_basename(name: str) -> str:
|
||
base = Path(str(name or "")).name.strip()
|
||
if not base or base in (".", ".."):
|
||
return "bild"
|
||
return base[:180]
|
||
|
||
|
||
def _attachment_meta_get(att_id: str) -> Optional[dict]:
|
||
store = _load_attachment_meta_store()
|
||
rec = (store.get("attachments") or {}).get(att_id)
|
||
return rec if isinstance(rec, dict) else None
|
||
|
||
|
||
def _attachment_can_view(rec: dict, my_pid: str, my_uid: str) -> bool:
|
||
if not rec or not my_pid or not my_uid:
|
||
return False
|
||
scope = str(rec.get("scope") or "")
|
||
if scope == "internal_dm":
|
||
if str(rec.get("practice_id") or "") != my_pid:
|
||
return False
|
||
if rec.get("status") != "committed":
|
||
return str(rec.get("user_id") or "") == my_uid
|
||
dck = str(rec.get("direct_conv_key") or "").strip()
|
||
parts = dck.split("|")
|
||
if len(parts) >= 4 and parts[1] == "direct":
|
||
return my_uid in (parts[2], parts[3])
|
||
return True
|
||
if scope == "external_dm":
|
||
if str(rec.get("practice_id") or "") != my_pid:
|
||
return False
|
||
if rec.get("status") != "committed":
|
||
return str(rec.get("user_id") or "") == my_uid
|
||
peer_p = str(rec.get("peer_practice_id") or "").strip()
|
||
peer_u = str(rec.get("peer_user_id") or "").strip()
|
||
conv = str(rec.get("conversation_id") or "").strip()
|
||
if not peer_p or not peer_u or not conv:
|
||
return False
|
||
try:
|
||
_external_dm_authorize_pair(my_pid, my_uid, peer_p, peer_u)
|
||
except HTTPException:
|
||
return False
|
||
return conv == _external_dm_conversation_id(my_pid, my_uid, peer_p, peer_u)
|
||
return False
|
||
|
||
|
||
def _attachment_row_for_message(rec: dict) -> dict:
|
||
return {
|
||
"attachment_id": str(rec.get("id") or ""),
|
||
"name": str(rec.get("original_name") or "bild"),
|
||
"mime_type": str(rec.get("mime_type") or "application/octet-stream"),
|
||
"size": int(rec.get("size") or 0),
|
||
"kind": "stored",
|
||
}
|
||
|
||
|
||
def _finalize_attachment_ids_internal(
|
||
ids: list[str],
|
||
pid: str,
|
||
sender_uid: str,
|
||
recipient_uid: str,
|
||
conv_key: str,
|
||
) -> list[dict]:
|
||
out: list[dict] = []
|
||
if not ids:
|
||
return out
|
||
if len(ids) > _ATTACHMENT_MAX_PER_MESSAGE:
|
||
raise HTTPException(status_code=400, detail="Zu viele Anhaenge")
|
||
store = _load_attachment_meta_store()
|
||
bucket = store.setdefault("attachments", {})
|
||
for raw_id in ids:
|
||
aid = str(raw_id or "").strip()
|
||
if not aid:
|
||
continue
|
||
rec = bucket.get(aid)
|
||
if not isinstance(rec, dict):
|
||
raise HTTPException(status_code=400, detail="Anhang nicht gefunden")
|
||
if rec.get("status") != "pending":
|
||
raise HTTPException(status_code=400, detail="Anhang bereits verwendet")
|
||
if str(rec.get("scope") or "") != "internal_dm":
|
||
raise HTTPException(status_code=400, detail="Anhang ungueltig")
|
||
if str(rec.get("practice_id") or "") != pid or str(rec.get("user_id") or "") != sender_uid:
|
||
raise HTTPException(status_code=403, detail="Anhang nicht erlaubt")
|
||
rec["status"] = "committed"
|
||
rec["direct_conv_key"] = conv_key
|
||
rec["recipient_user_id"] = recipient_uid
|
||
bucket[aid] = rec
|
||
out.append(_attachment_row_for_message(rec))
|
||
store["attachments"] = bucket
|
||
_save_attachment_meta_store(store)
|
||
return out
|
||
|
||
|
||
def _finalize_attachment_ids_external(
|
||
ids: list[str],
|
||
sender_pid: str,
|
||
sender_uid: str,
|
||
recipient_pid: str,
|
||
recipient_uid: str,
|
||
conv_id: str,
|
||
) -> list[dict]:
|
||
out: list[dict] = []
|
||
if not ids:
|
||
return out
|
||
if len(ids) > _ATTACHMENT_MAX_PER_MESSAGE:
|
||
raise HTTPException(status_code=400, detail="Zu viele Anhaenge")
|
||
store = _load_attachment_meta_store()
|
||
bucket = store.setdefault("attachments", {})
|
||
for raw_id in ids:
|
||
aid = str(raw_id or "").strip()
|
||
if not aid:
|
||
continue
|
||
rec = bucket.get(aid)
|
||
if not isinstance(rec, dict):
|
||
raise HTTPException(status_code=400, detail="Anhang nicht gefunden")
|
||
if rec.get("status") != "pending":
|
||
raise HTTPException(status_code=400, detail="Anhang bereits verwendet")
|
||
if str(rec.get("scope") or "") != "external_dm":
|
||
raise HTTPException(status_code=400, detail="Anhang ungueltig")
|
||
if str(rec.get("practice_id") or "") != sender_pid or str(rec.get("user_id") or "") != sender_uid:
|
||
raise HTTPException(status_code=403, detail="Anhang nicht erlaubt")
|
||
if str(rec.get("peer_practice_id") or "") != recipient_pid \
|
||
or str(rec.get("peer_user_id") or "") != recipient_uid:
|
||
raise HTTPException(status_code=400, detail="Anhang passt nicht zum Empfaenger")
|
||
rec["status"] = "committed"
|
||
rec["conversation_id"] = conv_id
|
||
bucket[aid] = rec
|
||
out.append(_attachment_row_for_message(rec))
|
||
store["attachments"] = bucket
|
||
_save_attachment_meta_store(store)
|
||
return out
|
||
|
||
|
||
# =====================================================================
|
||
# Cross-Praxis Direktnachrichten (eigenes Speicherfile, eigene Endpunkte)
|
||
# =====================================================================
|
||
|
||
|
||
class ExternalDmSendIn(BaseModel):
|
||
recipient_practice_id: str = ""
|
||
recipient_user_id: str = ""
|
||
text: str = ""
|
||
attachment_ids: list[str] = Field(default_factory=list)
|
||
|
||
|
||
@router.post("/external-messages/send")
|
||
async def empfang_external_dm_send(request: Request, payload: ExternalDmSendIn):
|
||
"""Sendet eine DM an einen Benutzer einer anderen Praxis (nur mit accepted Link).
|
||
|
||
practice_id / sender_user_id ausschliesslich aus Session bzw. Shell-Identitaet.
|
||
"""
|
||
s = _session_or_shell_identity(request)
|
||
sender_pid = (s.get("practice_id") or "").strip()
|
||
sender_uid = (s.get("user_id") or "").strip()
|
||
if not sender_pid or not sender_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
recipient_pid = (payload.recipient_practice_id or "").strip()
|
||
recipient_uid = (payload.recipient_user_id or "").strip()
|
||
raw_text = (payload.text or "").strip()
|
||
text = _clip_text(raw_text, _EXTERNAL_DM_TEXT_MAX)
|
||
att_ids = [str(x or "").strip() for x in (payload.attachment_ids or []) if str(x or "").strip()]
|
||
|
||
link_id, _link = _external_dm_authorize_send_direction(
|
||
sender_pid, sender_uid, recipient_pid, recipient_uid,
|
||
)
|
||
|
||
if not text and not att_ids:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Nachrichtstext oder Anhang erforderlich",
|
||
)
|
||
|
||
s_acc = _account_record_for_practice(sender_uid, sender_pid) or {}
|
||
r_acc = _account_record_for_practice(recipient_uid, recipient_pid) or {}
|
||
s_dn = str(s_acc.get("display_name") or "").strip() or sender_uid[:12]
|
||
r_dn = str(r_acc.get("display_name") or "").strip() or recipient_uid[:12]
|
||
|
||
conv_id = _external_dm_conversation_id(
|
||
sender_pid, sender_uid, recipient_pid, recipient_uid,
|
||
)
|
||
att_rows = _finalize_attachment_ids_external(
|
||
att_ids, sender_pid, sender_uid, recipient_pid, recipient_uid, conv_id,
|
||
)
|
||
msg_id = uuid.uuid4().hex[:12]
|
||
now = _utc_now_iso_z()
|
||
extras = {
|
||
"audience": "direct",
|
||
"conv_type": "external_dm",
|
||
"external_dm": True,
|
||
"conversation_id": conv_id,
|
||
"external_link_id": link_id,
|
||
"sender_user_id": sender_uid,
|
||
"recipient_user_id": recipient_uid,
|
||
"sender_practice_id": sender_pid,
|
||
"recipient_practice_id": recipient_pid,
|
||
}
|
||
if att_rows:
|
||
extras["attachments"] = att_rows
|
||
row = {
|
||
"id": msg_id,
|
||
"conv_type": "external_dm",
|
||
"conversation_id": conv_id,
|
||
"external_link_id": link_id,
|
||
"sender_practice_id": sender_pid,
|
||
"sender_user_id": sender_uid,
|
||
"sender_display_name": s_dn,
|
||
"recipient_practice_id": recipient_pid,
|
||
"recipient_user_id": recipient_uid,
|
||
"recipient_display_name": r_dn,
|
||
"kommentar": text or ("\u200b" if att_rows else ""),
|
||
"zeitstempel": now,
|
||
"empfangen": now,
|
||
"status": "offen",
|
||
"extras": extras,
|
||
}
|
||
|
||
msgs = _load_external_dm_messages()
|
||
msgs.insert(0, row)
|
||
_save_external_dm_messages(msgs)
|
||
|
||
try:
|
||
_pulse_bump(sender_pid, sender="external_dm")
|
||
_pulse_bump(recipient_pid, sender="external_dm")
|
||
except Exception:
|
||
pass
|
||
|
||
_log.info(
|
||
"AZA_EXT_DM_SEND conv=%s from=%s/%s to=%s/%s",
|
||
conv_id[:16], sender_pid[:12], (sender_uid or "")[:12],
|
||
recipient_pid[:12], (recipient_uid or "")[:12],
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"message_id": msg_id,
|
||
"conversation_id": conv_id,
|
||
"created_at": now,
|
||
})
|
||
|
||
|
||
@router.get("/external-messages/thread")
|
||
async def empfang_external_dm_thread(
|
||
request: Request,
|
||
peer_practice_id: str = Query("", alias="peer_practice_id"),
|
||
peer_user_id: str = Query("", alias="peer_user_id"),
|
||
):
|
||
"""Liefert Nachrichten einer externen 1:1-Konversation."""
|
||
s = _session_or_shell_identity(request)
|
||
my_pid = (s.get("practice_id") or "").strip()
|
||
my_uid = (s.get("user_id") or "").strip()
|
||
if not my_pid or not my_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
peer_pid = (peer_practice_id or "").strip()
|
||
peer_uid = (peer_user_id or "").strip()
|
||
link_id, _link = _external_dm_authorize_pair(my_pid, my_uid, peer_pid, peer_uid)
|
||
|
||
conv_id = _external_dm_conversation_id(my_pid, my_uid, peer_pid, peer_uid)
|
||
stored = _load_external_dm_messages()
|
||
thread_raw = [
|
||
m for m in stored
|
||
if isinstance(m, dict)
|
||
and str(m.get("conversation_id") or "") == conv_id
|
||
]
|
||
thread_raw.sort(key=_msg_chrono_sort_key)
|
||
out_msgs = [_external_dm_to_client_message(m) for m in thread_raw]
|
||
pulse = _pulse_get(my_pid)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"conversation_id": conv_id,
|
||
"external_link_id": link_id,
|
||
"messages": out_msgs,
|
||
"tick": int(pulse.get("tick", 0)),
|
||
})
|
||
|
||
|
||
@router.get("/external-messages/conversations")
|
||
async def empfang_external_dm_conversations(request: Request):
|
||
"""Letzte externe 1:1-Konversationen des angemeldeten Benutzers (serverseitig)."""
|
||
s = _session_or_shell_identity(request)
|
||
my_pid = (s.get("practice_id") or "").strip()
|
||
my_uid = (s.get("user_id") or "").strip()
|
||
if not my_pid or not my_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
stored = _load_external_dm_messages()
|
||
conv_last: dict[str, dict] = {}
|
||
for m in stored:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
if str(m.get("conv_type") or "") != "external_dm":
|
||
continue
|
||
cid = str(m.get("conversation_id") or "").strip()
|
||
if not cid:
|
||
continue
|
||
sp = str(m.get("sender_practice_id") or "").strip()
|
||
su = str(m.get("sender_user_id") or "").strip()
|
||
rp = str(m.get("recipient_practice_id") or "").strip()
|
||
ru = str(m.get("recipient_user_id") or "").strip()
|
||
if sp == my_pid and su == my_uid:
|
||
peer_p, peer_u = rp, ru
|
||
peer_dn = str(m.get("recipient_display_name") or "").strip()
|
||
elif rp == my_pid and ru == my_uid:
|
||
peer_p, peer_u = sp, su
|
||
peer_dn = str(m.get("sender_display_name") or "").strip()
|
||
else:
|
||
continue
|
||
try:
|
||
_external_dm_authorize_pair(my_pid, my_uid, peer_p, peer_u)
|
||
except HTTPException:
|
||
continue
|
||
ts = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
prev = conv_last.get(cid)
|
||
if prev and prev.get("last_message_at", "") >= ts:
|
||
continue
|
||
conv_last[cid] = {
|
||
"conversation_id": cid,
|
||
"kind": "external_dm",
|
||
"peer_practice_id": peer_p,
|
||
"peer_practice_name": _practice_name_safe(peer_p),
|
||
"peer_user_id": peer_u,
|
||
"peer_display_name": peer_dn or peer_u[:12],
|
||
"external_link_id": str(m.get("external_link_id") or ""),
|
||
"last_message_at": ts,
|
||
}
|
||
|
||
rows = sorted(
|
||
conv_last.values(),
|
||
key=lambda x: str(x.get("last_message_at") or ""),
|
||
reverse=True,
|
||
)
|
||
return JSONResponse(content={"success": True, "conversations": rows})
|
||
|
||
|
||
class ExternalDmMarkReadIn(BaseModel):
|
||
peer_practice_id: str = ""
|
||
peer_user_id: str = ""
|
||
|
||
|
||
@router.get("/external-messages/unread-summary")
|
||
async def empfang_external_dm_unread_summary(request: Request):
|
||
"""Ungelesene externe 1:1-Threads (serverseitiger Lesestatus)."""
|
||
s = _session_or_shell_identity(request)
|
||
my_pid = (s.get("practice_id") or "").strip()
|
||
my_uid = (s.get("user_id") or "").strip()
|
||
if not my_pid or not my_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
total, items = _external_dm_unread_aggregate(my_pid, my_uid)
|
||
return JSONResponse(
|
||
content={"success": True, "total_unread": total, "items": items},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
|
||
|
||
@router.post("/external-messages/mark-read")
|
||
async def empfang_external_dm_mark_read(request: Request, body: ExternalDmMarkReadIn):
|
||
"""Setzt Lesestatus fuer einen externen Thread auf die juengste bekannte Nachricht."""
|
||
s = _session_or_shell_identity(request)
|
||
my_pid = (s.get("practice_id") or "").strip()
|
||
my_uid = (s.get("user_id") or "").strip()
|
||
if not my_pid or not my_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
peer_pid = (body.peer_practice_id or "").strip()
|
||
peer_uid = (body.peer_user_id or "").strip()
|
||
if not peer_pid or not peer_uid:
|
||
raise HTTPException(status_code=400, detail="peer_practice_id und peer_user_id erforderlich")
|
||
_external_dm_authorize_pair(my_pid, my_uid, peer_pid, peer_uid)
|
||
conv_id = _external_dm_conversation_id(my_pid, my_uid, peer_pid, peer_uid)
|
||
max_iso = _utc_now_iso_z()
|
||
max_ts = _parse_msg_instant_utc_ts(max_iso)
|
||
for m in _load_external_dm_messages():
|
||
if not isinstance(m, dict):
|
||
continue
|
||
if str(m.get("conversation_id") or "") != conv_id:
|
||
continue
|
||
ts_raw = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
t = _parse_msg_instant_utc_ts(ts_raw)
|
||
if t >= max_ts:
|
||
max_ts = t
|
||
max_iso = ts_raw
|
||
_external_dm_set_last_read_iso(my_pid, my_uid, conv_id, max_iso)
|
||
return JSONResponse(content={"success": True, "conversation_id": conv_id})
|
||
|
||
|
||
@router.post("/attachments/upload")
|
||
async def empfang_attachment_upload(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
scope: str = Form("internal_dm"),
|
||
peer_practice_id: str = Form(""),
|
||
peer_user_id: str = Form(""),
|
||
):
|
||
"""Laedt einen Bild-Anhang hoch (pending bis zur ersten zugehoerigen Nachricht)."""
|
||
s = _session_or_shell_identity(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
|
||
sc = (scope or "").strip().lower()
|
||
if sc not in ("internal_dm", "external_dm"):
|
||
raise HTTPException(status_code=400, detail="Ungueltiger scope")
|
||
mime = _normalize_upload_mime(file.content_type or "", file.filename or "")
|
||
if not mime:
|
||
raise HTTPException(status_code=400, detail="Dateityp nicht erlaubt")
|
||
|
||
peer_p = (peer_practice_id or "").strip()
|
||
peer_u = (peer_user_id or "").strip()
|
||
if sc == "external_dm":
|
||
_external_dm_authorize_send_direction(pid, uid, peer_p, peer_u)
|
||
|
||
_ensure_attachments_dir()
|
||
aid = _generate_attachment_id()
|
||
dest = _ATTACHMENTS_DIR / f"{aid}.bin"
|
||
safe_name = _safe_attachment_basename(file.filename)
|
||
sz = 0
|
||
try:
|
||
with open(dest, "wb") as outf:
|
||
while True:
|
||
chunk = await file.read(1024 * 1024)
|
||
if not chunk:
|
||
break
|
||
sz += len(chunk)
|
||
if sz > _ATTACHMENT_MAX_BYTES:
|
||
raise HTTPException(status_code=400, detail="Datei zu gross")
|
||
outf.write(chunk)
|
||
except HTTPException:
|
||
try:
|
||
dest.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
raise
|
||
except Exception:
|
||
try:
|
||
dest.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
raise HTTPException(status_code=500, detail="Speichern fehlgeschlagen")
|
||
|
||
now = _utc_now_iso_z()
|
||
store = _load_attachment_meta_store()
|
||
bucket = store.setdefault("attachments", {})
|
||
rec: dict = {
|
||
"id": aid,
|
||
"scope": sc,
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
"status": "pending",
|
||
"original_name": safe_name,
|
||
"mime_type": mime,
|
||
"size": sz,
|
||
"created_at": now,
|
||
}
|
||
if sc == "external_dm":
|
||
rec["peer_practice_id"] = peer_p
|
||
rec["peer_user_id"] = peer_u
|
||
bucket[aid] = rec
|
||
store["attachments"] = bucket
|
||
_save_attachment_meta_store(store)
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"attachment_id": aid,
|
||
"mime_type": mime,
|
||
"size": sz,
|
||
"name": safe_name,
|
||
},
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
|
||
|
||
@router.get("/attachments/{attachment_id}/file")
|
||
async def empfang_attachment_get_file(attachment_id: str, request: Request):
|
||
"""Liefert Dateiinhalt nur mit passender Session/Berechtigung."""
|
||
s = _session_or_shell_identity(request)
|
||
my_pid = (s.get("practice_id") or "").strip()
|
||
my_uid = (s.get("user_id") or "").strip()
|
||
if not my_pid or not my_uid:
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
aid = (attachment_id or "").strip()
|
||
rec = _attachment_meta_get(aid)
|
||
if not rec:
|
||
raise HTTPException(status_code=404, detail="Nicht gefunden")
|
||
if not _attachment_can_view(rec, my_pid, my_uid):
|
||
raise HTTPException(status_code=403, detail="Kein Zugriff")
|
||
path = _ATTACHMENTS_DIR / f"{aid}.bin"
|
||
if not path.is_file():
|
||
raise HTTPException(status_code=404, detail="Nicht gefunden")
|
||
return FileResponse(
|
||
path,
|
||
media_type=str(rec.get("mime_type") or "application/octet-stream"),
|
||
filename=str(rec.get("original_name") or "bild"),
|
||
headers={"Cache-Control": "private, no-store"},
|
||
)
|
||
|
||
|
||
@router.post("/external-contacts/request-practice")
|
||
async def empfang_external_contacts_request_practice(request: Request):
|
||
"""Praxis-zu-Praxis-Verbindung via CHAT-Code (Code = beidseitige Genehmigung).
|
||
|
||
Body: ``{"code": "CHAT-...-...", "note": "...optional..."}``
|
||
Wird intern auf den bestehenden practice-link-Pfad gemappt
|
||
(contact_type=external_practice, status=accepted).
|
||
"""
|
||
s = _session_or_shell_identity(request)
|
||
source_pid = (s.get("practice_id") or "").strip()
|
||
source_uid = (s.get("user_id") or "").strip()
|
||
if not source_pid or not source_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
if not isinstance(body, dict):
|
||
body = {}
|
||
raw_code = (body.get("code") or body.get("invite_code") or "").strip()
|
||
note = (body.get("note") or "")[:500]
|
||
if not raw_code:
|
||
raise HTTPException(status_code=400, detail="code erforderlich")
|
||
target_pid = _lookup_practice_id_by_invite(raw_code)
|
||
if not target_pid:
|
||
raise HTTPException(status_code=404, detail="Code ist keiner Praxis zugeordnet oder bereits abgelaufen.")
|
||
if target_pid == source_pid:
|
||
raise HTTPException(status_code=400, detail="Sie koennen Ihre eigene Praxis nicht als externen Kontakt hinzufuegen.")
|
||
|
||
practices = _load_practices()
|
||
src_name = str((practices.get(source_pid) or {}).get("name") or "").strip()
|
||
tgt_name = str((practices.get(target_pid) or {}).get("name") or "").strip()
|
||
store = _load_practice_links_store()
|
||
existing = None
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
a = link.get("source_practice_id"); b = link.get("target_practice_id")
|
||
if {a, b} == {source_pid, target_pid} and _link_contact_type(link) == _CONTACT_TYPE_PRACTICE:
|
||
existing = link
|
||
break
|
||
now = _now_z()
|
||
if existing is not None:
|
||
existing["contact_type"] = _CONTACT_TYPE_PRACTICE
|
||
existing["status"] = "accepted"
|
||
existing["updated_at"] = now
|
||
if not existing.get("invite_code_used"):
|
||
existing["invite_code_used"] = raw_code
|
||
if note and not existing.get("note"):
|
||
existing["note"] = note
|
||
if existing.get("source_practice_id") == source_pid:
|
||
existing["source_practice_name"] = src_name
|
||
existing["target_practice_name"] = tgt_name
|
||
else:
|
||
existing["source_practice_name"] = tgt_name
|
||
existing["target_practice_name"] = src_name
|
||
_persist_link_update(existing["id"], existing)
|
||
out = existing
|
||
else:
|
||
out = {
|
||
"id": _generate_practice_link_id(),
|
||
"contact_type": _CONTACT_TYPE_PRACTICE,
|
||
"source_practice_id": source_pid,
|
||
"target_practice_id": target_pid,
|
||
"source_practice_name": src_name,
|
||
"target_practice_name": tgt_name,
|
||
"status": "accepted",
|
||
"requested_by_user_id": source_uid,
|
||
"created_by_user_id": source_uid,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"invite_code_used": raw_code,
|
||
"note": note,
|
||
}
|
||
store.setdefault("links", []).append(out)
|
||
_save_practice_links_store(store)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTCONT_PRACTICE source=%s target=%s status=%s",
|
||
(source_pid or "")[:16], (target_pid or "")[:16], out["status"],
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_contact": _serialize_external_contact_for(source_pid, source_uid, out),
|
||
})
|
||
|
||
|
||
@router.post("/external-contacts/request-person")
|
||
async def empfang_external_contacts_request_person(request: Request):
|
||
"""Persoenliche 1:1-externe Kontaktanfrage an einen konkreten Benutzer.
|
||
|
||
Body: ``{"code": "CHAT-...", "target_user_id": "...", "note": "..."}``
|
||
- code: CHAT-Code der **Zielpraxis** (nur Mandanten-Schutz, keine
|
||
Praxisverknuepfung).
|
||
- target_user_id: verpflichtend — Benutzer-ID des Empfaengers in
|
||
der Zielpraxis (spaeter: persoenlicher Einladungscode-Token).
|
||
- note: optional.
|
||
|
||
``contact_type=external_person``, status ``pending_outgoing`` bis der
|
||
Zielbenutzer annimmt.
|
||
"""
|
||
s = _session_or_shell_identity(request)
|
||
source_pid = (s.get("practice_id") or "").strip()
|
||
source_uid = (s.get("user_id") or "").strip()
|
||
source_dn = (s.get("display_name") or "").strip()
|
||
if not source_pid or not source_uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
if not isinstance(body, dict):
|
||
body = {}
|
||
raw_code = (body.get("code") or body.get("invite_code") or "").strip()
|
||
note = (body.get("note") or "")[:500]
|
||
target_user_id = (
|
||
body.get("target_user_id") or body.get("target_user") or ""
|
||
).strip()
|
||
if not raw_code:
|
||
raise HTTPException(status_code=400, detail="code erforderlich")
|
||
if not target_user_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
"target_user_id erforderlich: persoenliche Kontakte sind nur "
|
||
"personenbezogen. Ohne Ziel-Benutzer-ID bitte "
|
||
"«Externe Praxis verbinden» nutzen oder einen persoenlichen "
|
||
"Einladungslink (Folgeausbaustufe)."
|
||
),
|
||
)
|
||
target_pid = _lookup_practice_id_by_invite(raw_code)
|
||
if not target_pid:
|
||
raise HTTPException(status_code=404, detail="Code ist keiner Praxis zugeordnet oder bereits abgelaufen.")
|
||
if target_pid == source_pid:
|
||
raise HTTPException(status_code=400, detail="Sie koennen sich nicht als externen Kontakt Ihrer eigenen Praxis anfragen.")
|
||
|
||
tgt_acc = _account_record_for_practice(target_user_id, target_pid)
|
||
if not tgt_acc or not _account_is_sendable(tgt_acc):
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Zielbenutzer in dieser Praxis nicht gefunden oder nicht erreichbar.",
|
||
)
|
||
|
||
practices = _load_practices()
|
||
src_name = str((practices.get(source_pid) or {}).get("name") or "").strip()
|
||
tgt_name = str((practices.get(target_pid) or {}).get("name") or "").strip()
|
||
if not source_dn:
|
||
try:
|
||
accounts = _load_accounts()
|
||
acc = accounts.get(source_uid) or {}
|
||
source_dn = str(acc.get("display_name") or "").strip() or "Externer Benutzer"
|
||
except Exception:
|
||
source_dn = "Externer Benutzer"
|
||
|
||
target_display_name = str(tgt_acc.get("display_name") or "").strip()
|
||
|
||
store = _load_practice_links_store()
|
||
existing = None
|
||
for link in store.get("links") or []:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
|
||
continue
|
||
if link.get("source_practice_id") == source_pid \
|
||
and link.get("target_practice_id") == target_pid \
|
||
and link.get("source_user_id") == source_uid \
|
||
and (link.get("target_user_id") or "").strip() == target_user_id \
|
||
and link.get("status") not in ("removed", "rejected"):
|
||
existing = link
|
||
break
|
||
|
||
now = _now_z()
|
||
if existing is not None:
|
||
existing["contact_type"] = _CONTACT_TYPE_PERSON
|
||
existing["status"] = "pending_outgoing"
|
||
existing["updated_at"] = now
|
||
existing["source_display_name"] = source_dn
|
||
existing["target_user_id"] = target_user_id
|
||
existing["target_display_name"] = target_display_name
|
||
if note:
|
||
existing["note"] = note
|
||
existing["source_practice_name"] = src_name
|
||
existing["target_practice_name"] = tgt_name
|
||
if not existing.get("invite_code_used"):
|
||
existing["invite_code_used"] = raw_code
|
||
_persist_link_update(existing["id"], existing)
|
||
out = existing
|
||
else:
|
||
out = {
|
||
"id": _generate_practice_link_id(),
|
||
"contact_type": _CONTACT_TYPE_PERSON,
|
||
"source_practice_id": source_pid,
|
||
"target_practice_id": target_pid,
|
||
"source_user_id": source_uid,
|
||
"source_display_name": source_dn,
|
||
"target_user_id": target_user_id,
|
||
"target_display_name": target_display_name,
|
||
"source_practice_name": src_name,
|
||
"target_practice_name": tgt_name,
|
||
"status": "pending_outgoing",
|
||
"requested_by_user_id": source_uid,
|
||
"created_by_user_id": source_uid,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"invite_code_used": raw_code,
|
||
"note": note,
|
||
}
|
||
store.setdefault("links", []).append(out)
|
||
_save_practice_links_store(store)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTCONT_PERSON_REQ source=%s target=%s tgtuser=%s status=%s",
|
||
(source_pid or "")[:16],
|
||
(target_pid or "")[:16],
|
||
(target_user_id or "")[:12],
|
||
out["status"],
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_contact": _serialize_external_contact_for(source_pid, source_uid, out),
|
||
})
|
||
|
||
|
||
@router.post("/external-contacts/{link_id}/accept")
|
||
async def empfang_external_contacts_accept(link_id: str, request: Request):
|
||
"""Akzeptiert eine eingehende externe Anfrage.
|
||
|
||
- external_practice: Administrator der Zielpraxis.
|
||
- external_person: nur der adressierte Zielbenutzer (target_user_id);
|
||
Legacy ohne target_user_id: Administrator der Zielpraxis.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
is_adm = _is_admin_session(s)
|
||
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
|
||
if not link:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
if link.get("target_practice_id") != pid:
|
||
raise HTTPException(status_code=403, detail="Nur die Zielpraxis darf Anfragen genehmigen.")
|
||
ctype = _link_contact_type(link)
|
||
if ctype == _CONTACT_TYPE_PRACTICE:
|
||
if not is_adm:
|
||
raise HTTPException(status_code=403, detail="Nur Administratoren der Zielpraxis duerfen Anfragen akzeptieren.")
|
||
elif ctype == _CONTACT_TYPE_PERSON:
|
||
if not _person_may_moderate_incoming(link, uid, is_adm):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Nur der adressierte Benutzer darf diese persoenliche Anfrage annehmen.",
|
||
)
|
||
else:
|
||
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
|
||
link["status"] = "accepted"
|
||
link["approved_by_user_id"] = uid
|
||
link["accepted_by_user_id"] = uid
|
||
link["updated_at"] = _now_z()
|
||
_persist_link_update(link_id, link)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTCONT_ACCEPTED practice=%s link=%s type=%s",
|
||
(pid or "")[:16], (link_id or "")[:16], ctype,
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_contact": _serialize_external_contact_for(pid, uid, link),
|
||
})
|
||
|
||
|
||
@router.post("/external-contacts/{link_id}/reject")
|
||
async def empfang_external_contacts_reject(link_id: str, request: Request):
|
||
"""Lehnt eine externe Anfrage ab (Praxis: Admin; Person: Zielbenutzer)."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
is_adm = _is_admin_session(s)
|
||
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
|
||
if not link:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
if link.get("target_practice_id") != pid:
|
||
raise HTTPException(status_code=403, detail="Nur die Zielpraxis darf Anfragen ablehnen.")
|
||
ctype = _link_contact_type(link)
|
||
if ctype == _CONTACT_TYPE_PRACTICE:
|
||
if not is_adm:
|
||
raise HTTPException(status_code=403, detail="Nur Administratoren der Zielpraxis duerfen Anfragen ablehnen.")
|
||
elif ctype == _CONTACT_TYPE_PERSON:
|
||
if not _person_may_moderate_incoming(link, uid, is_adm):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung fuer diese persoenliche Anfrage.")
|
||
else:
|
||
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
|
||
link["status"] = "rejected"
|
||
link["approved_by_user_id"] = uid
|
||
link["updated_at"] = _now_z()
|
||
_persist_link_update(link_id, link)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTCONT_REJECTED practice=%s link=%s type=%s",
|
||
(pid or "")[:16], (link_id or "")[:16], ctype,
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_contact": _serialize_external_contact_for(pid, uid, link),
|
||
})
|
||
|
||
|
||
@router.post("/external-contacts/{link_id}/block")
|
||
async def empfang_external_contacts_block(link_id: str, request: Request):
|
||
"""Blockiert eine externe Verbindung (Praxis: Admin; Person: Zielbenutzer)."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
is_adm = _is_admin_session(s)
|
||
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
|
||
if not link:
|
||
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
|
||
if link.get("target_practice_id") != pid:
|
||
raise HTTPException(status_code=403, detail="Nur die Zielpraxis darf Verbindungen blockieren.")
|
||
ctype = _link_contact_type(link)
|
||
if ctype == _CONTACT_TYPE_PRACTICE:
|
||
if not is_adm:
|
||
raise HTTPException(status_code=403, detail="Nur Administratoren duerfen blockieren.")
|
||
elif ctype == _CONTACT_TYPE_PERSON:
|
||
if not _person_may_moderate_incoming(link, uid, is_adm):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||
else:
|
||
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
|
||
link["status"] = "blocked"
|
||
link["approved_by_user_id"] = uid
|
||
link["blocked_by_practice_id"] = pid
|
||
link["updated_at"] = _now_z()
|
||
_persist_link_update(link_id, link)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTCONT_BLOCKED practice=%s link=%s type=%s",
|
||
(pid or "")[:16], (link_id or "")[:16], ctype,
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"external_contact": _serialize_external_contact_for(pid, uid, link),
|
||
})
|
||
|
||
|
||
@router.delete("/external-contacts/{link_id}")
|
||
async def empfang_external_contacts_remove(link_id: str, request: Request):
|
||
"""Entfernt die externe Verbindung aus eigener Sicht (status=removed).
|
||
|
||
Persoenliche Kontakte: nur die beiden beteiligten Benutzer (bzw. Legacy-Admin).
|
||
Praxis-zu-Praxis: jedes Mitglied der beteiligten Praxis.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
is_adm = _is_admin_session(s)
|
||
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
|
||
if not link:
|
||
return JSONResponse(content={"success": True, "removed": 0})
|
||
if not _person_link_may_remove(link, pid, uid, is_adm):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Entfernen dieser Verbindung.")
|
||
link["status"] = "removed"
|
||
link["updated_at"] = _now_z()
|
||
_persist_link_update(link_id, link)
|
||
_log.info(
|
||
"AZA_EMPFANG_EXTCONT_REMOVED practice=%s link=%s type=%s",
|
||
(pid or "")[:16], (link_id or "")[:16], _link_contact_type(link),
|
||
)
|
||
return JSONResponse(content={"success": True, "removed": 1})
|
||
|
||
|
||
# =====================================================================
|
||
# Admin-Verwaltung pro Praxis (license-anchored, mit Last-Admin-Schutz)
|
||
# =====================================================================
|
||
#
|
||
# Sicherheitsmodell:
|
||
# - Adminrolle ist striktes practice-Property. Ein Admin von Praxis A
|
||
# ist NICHT automatisch Admin in Praxis B.
|
||
# - Der allererste Admin entsteht ueber das Hauptprogramm bei der
|
||
# Lizenzaktivierung (siehe auth/provision: bootstrap_admin_allowed).
|
||
# - Spaetere Admins werden nur durch einen bereits autorisierten
|
||
# Admin (rolle=admin in derselben practice_id) gesetzt oder
|
||
# entzogen. Ausnahme: ist eine Praxis komplett adminlos, kann ein
|
||
# Mitglied dieser Praxis sich selbst ueber /repair-no-admin als
|
||
# Admin festlegen ("Self-Repair", aber NUR in der eigenen Praxis).
|
||
# - Externe Kontakte werden niemals als lokale Admins gefuehrt:
|
||
# accounts.json enthaelt nur lokale Benutzer; externe Personen sind
|
||
# in practice_links.json gespeichert und nie Teil von accounts.
|
||
# - Letzter Admin kann nicht entzogen werden (HTTP 400). Ein anderer
|
||
# Admin muss erst gesetzt werden.
|
||
|
||
_ADMIN_SOURCE_LICENSE = "license_activation"
|
||
_ADMIN_SOURCE_MANUAL = "manual_admin_assignment"
|
||
_ADMIN_SOURCE_REPAIR = "manual_admin_assignment" # gleicher Quelltext im Audit
|
||
_ADMIN_SOURCE_LEGACY = "legacy"
|
||
_ADMIN_SOURCE_LICENSE_JOIN_EXISTING = "license_join_existing"
|
||
|
||
|
||
def _account_has_practice_admin_privileges(acc: Optional[dict]) -> bool:
|
||
"""Praxis-Administrator im Sinn der Admin-APIs (Rolle admin oder Arzt mit Office-Lizenz-Beitritt)."""
|
||
if not isinstance(acc, dict):
|
||
return False
|
||
r = (acc.get("role") or "").strip().lower()
|
||
if r == "admin":
|
||
return True
|
||
if r == "arzt" and (acc.get("admin_source") or "").strip() == _ADMIN_SOURCE_LICENSE_JOIN_EXISTING:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _public_account_for_admin_ui(acc: dict) -> dict:
|
||
"""Pro Account ein kompakter Diagnose-/Admin-UI-Datensatz.
|
||
|
||
Es werden KEINE Passwort-Hashes oder sensiblen Felder preisgegeben.
|
||
`admin_source` wird mit "legacy" befuellt, wenn der Account Admin
|
||
ist, aber keine explizite Quelle gespeichert hat (historische Daten).
|
||
"""
|
||
role = (acc.get("role") or "").strip()
|
||
src_stored = (acc.get("admin_source") or "").strip()
|
||
if role == "admin" and not src_stored:
|
||
src_stored = _ADMIN_SOURCE_LEGACY
|
||
ap = _account_has_practice_admin_privileges(acc)
|
||
src_out = ""
|
||
if ap:
|
||
src_out = (acc.get("admin_source") or "").strip()
|
||
if role.lower() == "admin" and not src_out:
|
||
src_out = _ADMIN_SOURCE_LEGACY
|
||
return {
|
||
"user_id": str(acc.get("user_id") or ""),
|
||
"display_name": str(acc.get("display_name") or ""),
|
||
"login_name": str(acc.get("login_name") or ""),
|
||
"email": str(acc.get("email") or ""),
|
||
"role": role,
|
||
"status": str(acc.get("status") or "active"),
|
||
"admin_source": src_out,
|
||
"created": str(acc.get("created") or ""),
|
||
"last_login": str(acc.get("last_login") or ""),
|
||
}
|
||
|
||
|
||
def _admins_for_practice(accounts: dict, pid: str) -> list:
|
||
return [
|
||
a for a in accounts.values()
|
||
if a.get("practice_id") == pid
|
||
and _account_has_practice_admin_privileges(a)
|
||
and (a.get("status") or "active") != "deactivated"
|
||
]
|
||
|
||
|
||
def _members_for_practice(accounts: dict, pid: str) -> list:
|
||
return [a for a in accounts.values() if a.get("practice_id") == pid]
|
||
|
||
|
||
def _require_self_practice_admin(request: Request) -> dict:
|
||
"""Liefert die Session, wenn sie Admin-Rolle in der eigenen Praxis hat."""
|
||
s = _require_session(request)
|
||
if not _is_admin_session(s):
|
||
raise HTTPException(status_code=403, detail="Nur Administratoren der eigenen Praxis duerfen diese Aktion ausfuehren.")
|
||
return s
|
||
|
||
|
||
@router.get("/admin/diagnosis")
|
||
async def empfang_admin_diagnosis(request: Request):
|
||
"""Diagnose-Sicht der EIGENEN Praxis fuer den Profil-UI.
|
||
|
||
Erfordert eine bestehende Session; jeder Benutzer der Praxis darf
|
||
seine eigene Praxis-Diagnose lesen (auch MPA/Arzt). Es werden NUR
|
||
nicht-sensible Felder ausgegeben (keine Passwoerter, keine Patienten-
|
||
daten, keine externen Kontakte).
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
practices = _load_practices()
|
||
pdata = practices.get(pid) or {}
|
||
accounts = _load_accounts()
|
||
members = _members_for_practice(accounts, pid)
|
||
members.sort(
|
||
key=lambda a: (
|
||
not _account_has_practice_admin_privileges(a),
|
||
str(a.get("display_name") or "").lower(),
|
||
),
|
||
)
|
||
admins = _admins_for_practice(accounts, pid)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"practice": {
|
||
"practice_id": pid,
|
||
"practice_name": str(pdata.get("name") or "").strip(),
|
||
"admin_email_on_practice": str(pdata.get("admin_email") or "").strip(),
|
||
"created": str(pdata.get("created") or ""),
|
||
},
|
||
"has_admin": bool(admins),
|
||
"admin_count": len(admins),
|
||
"members": [_public_account_for_admin_ui(a) for a in members],
|
||
"own_user_id": str(s.get("user_id") or ""),
|
||
"own_role": str(s.get("role") or ""),
|
||
})
|
||
|
||
|
||
@router.get("/admin/admins")
|
||
async def empfang_admin_admins(request: Request):
|
||
"""Kurzliste der Admins der eigenen Praxis. Fuer Header/Quick-Info."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
accounts = _load_accounts()
|
||
admins = _admins_for_practice(accounts, pid)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"admins": [_public_account_for_admin_ui(a) for a in admins],
|
||
})
|
||
|
||
|
||
@router.post("/admin/set-admin/{user_id}")
|
||
async def empfang_admin_set_admin(user_id: str, request: Request):
|
||
"""Bestehender Admin der Praxis setzt einen anderen Benutzer derselben
|
||
Praxis ebenfalls als Admin. Externe Kontakte (sind nicht in accounts)
|
||
sind automatisch ausgeschlossen.
|
||
"""
|
||
s = _require_self_practice_admin(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
accounts = _load_accounts()
|
||
target = accounts.get(user_id)
|
||
if not target or target.get("practice_id") != pid:
|
||
raise HTTPException(status_code=404, detail="Benutzer nicht in dieser Praxis gefunden")
|
||
if (target.get("status") or "active") == "deactivated":
|
||
raise HTTPException(status_code=400, detail="Deaktivierte Konten koennen nicht zum Admin gemacht werden.")
|
||
if _account_has_practice_admin_privileges(target):
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"user": _public_account_for_admin_ui(target),
|
||
"no_op": True,
|
||
})
|
||
target["role"] = "admin"
|
||
target["admin_source"] = _ADMIN_SOURCE_MANUAL
|
||
target["admin_assigned_by_user_id"] = str(s.get("user_id") or "")
|
||
target["admin_assigned_at"] = _now_z()
|
||
accounts[user_id] = target
|
||
_save_accounts(accounts)
|
||
_log.info(
|
||
"AZA_EMPFANG_ADMIN_SET practice=%s by=%s target=%s",
|
||
(pid or "")[:16], (s.get("user_id") or "")[:16], (user_id or "")[:16],
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"user": _public_account_for_admin_ui(target),
|
||
})
|
||
|
||
|
||
@router.post("/admin/revoke-admin/{user_id}")
|
||
async def empfang_admin_revoke_admin(user_id: str, request: Request):
|
||
"""Adminrechte eines Benutzers in der eigenen Praxis entziehen.
|
||
|
||
Last-Admin-Schutz: wenn der Zielbenutzer der letzte verbleibende
|
||
Admin der Praxis waere, wird mit HTTP 400 abgelehnt.
|
||
"""
|
||
s = _require_self_practice_admin(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
accounts = _load_accounts()
|
||
target = accounts.get(user_id)
|
||
if not target or target.get("practice_id") != pid:
|
||
raise HTTPException(status_code=404, detail="Benutzer nicht in dieser Praxis gefunden")
|
||
if not _account_has_practice_admin_privileges(target):
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"user": _public_account_for_admin_ui(target),
|
||
"no_op": True,
|
||
})
|
||
admins_now = _admins_for_practice(accounts, pid)
|
||
other_admins = [a for a in admins_now if a.get("user_id") != user_id]
|
||
if not other_admins:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Letzter Admin kann nicht entzogen werden. Bitte zuerst einen anderen Benutzer als Admin festlegen.",
|
||
)
|
||
target["role"] = "arzt"
|
||
target["admin_source"] = ""
|
||
target["admin_revoked_by_user_id"] = str(s.get("user_id") or "")
|
||
target["admin_revoked_at"] = _now_z()
|
||
accounts[user_id] = target
|
||
_save_accounts(accounts)
|
||
_log.info(
|
||
"AZA_EMPFANG_ADMIN_REVOKE practice=%s by=%s target=%s",
|
||
(pid or "")[:16], (s.get("user_id") or "")[:16], (user_id or "")[:16],
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"user": _public_account_for_admin_ui(target),
|
||
})
|
||
|
||
|
||
@router.post("/admin/repair-no-admin")
|
||
async def empfang_admin_repair_no_admin(request: Request):
|
||
"""Self-Repair: Wenn die Praxis aktuell KEINEN Admin hat, darf
|
||
der aktuell angemeldete Benutzer sich selbst als Admin der eigenen
|
||
Praxis festlegen. Wenn bereits ein Admin existiert, wird mit
|
||
HTTP 400 abgelehnt (dann muss /set-admin durch den Admin verwendet
|
||
werden).
|
||
"""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
accounts = _load_accounts()
|
||
admins_now = _admins_for_practice(accounts, pid)
|
||
if admins_now:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
"Diese Praxis hat bereits einen Administrator. Bitte den "
|
||
"bestehenden Administrator bitten, Sie zum Administrator zu "
|
||
"machen."
|
||
),
|
||
)
|
||
target = accounts.get(uid)
|
||
if not target or target.get("practice_id") != pid:
|
||
raise HTTPException(status_code=403, detail="Benutzer gehoert nicht zur eigenen Praxis.")
|
||
if (target.get("status") or "active") == "deactivated":
|
||
raise HTTPException(status_code=400, detail="Deaktivierte Konten koennen sich nicht zum Admin machen.")
|
||
target["role"] = "admin"
|
||
target["admin_source"] = _ADMIN_SOURCE_REPAIR
|
||
target["admin_assigned_at"] = _now_z()
|
||
accounts[uid] = target
|
||
_save_accounts(accounts)
|
||
_log.info(
|
||
"AZA_EMPFANG_ADMIN_SELF_REPAIR practice=%s user=%s",
|
||
(pid or "")[:16], (uid or "")[:16],
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"user": _public_account_for_admin_ui(target),
|
||
})
|
||
|
||
|
||
@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 not _is_admin_session(s):
|
||
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", ""),
|
||
})
|
||
|
||
|
||
def _auth_provision_profile_attach(
|
||
practices: dict, pid: str, account: Optional[dict],
|
||
) -> dict:
|
||
"""Lesbare Profil-Metadaten fuer Desktop-Sync (keine Secrets)."""
|
||
p = practices.get(pid) or {}
|
||
out: dict = {
|
||
"practice_name": str(p.get("name") or "").strip(),
|
||
"practice_specialty": str(p.get("specialty") or "").strip(),
|
||
"practice_phone": str(p.get("phone") or "").strip(),
|
||
"practice_contact_email": str(p.get("contact_email") or "").strip(),
|
||
}
|
||
try:
|
||
from stripe_routes import lookup_license_email_for_practice
|
||
|
||
lm = (lookup_license_email_for_practice(pid) or "").strip()
|
||
if lm:
|
||
out["license_customer_email"] = lm
|
||
except Exception:
|
||
pass
|
||
if account and isinstance(account, dict):
|
||
out["account_display_name"] = str(account.get("display_name") or "").strip()
|
||
out["account_email"] = str(account.get("email") or "").strip()
|
||
out["account_role"] = str(account.get("role") or "").strip()
|
||
out["account_specialty"] = str(
|
||
account.get("specialty") or account.get("desktop_specialty") or "",
|
||
).strip()
|
||
out["account_title"] = str(
|
||
account.get("title") or account.get("desktop_title") or "",
|
||
).strip()
|
||
out["account_job_function"] = str(
|
||
account.get("job_function") or account.get("function") or "",
|
||
).strip()
|
||
return out
|
||
|
||
|
||
def _weak_practice_public_name(name: str) -> bool:
|
||
s = " ".join((name or "").strip().lower().split())
|
||
return (not s) or s in frozenset({"meine praxis"}) or len(s) < 2
|
||
|
||
|
||
def _practice_profile_public_shape(p: dict, pid: str) -> dict:
|
||
return {
|
||
"practice_id": pid,
|
||
"name": str(p.get("name") or "").strip(),
|
||
"specialty": str(p.get("specialty") or "").strip(),
|
||
"phone": str(p.get("phone") or "").strip(),
|
||
"address": str(p.get("address") or "").strip(),
|
||
"website": str(p.get("website") or "").strip(),
|
||
"contact_email": str(p.get("contact_email") or "").strip(),
|
||
"admin_email": str(p.get("admin_email") or "").strip(),
|
||
"profile_updated_at": str(p.get("profile_updated_at") or "").strip(),
|
||
"profile_updated_by_user_id": str(
|
||
p.get("profile_updated_by_user_id") or "",
|
||
).strip(),
|
||
}
|
||
|
||
|
||
def _user_profile_public_shape(acc: dict) -> dict:
|
||
return {
|
||
"user_id": str(acc.get("user_id") or "").strip(),
|
||
"display_name": str(acc.get("display_name") or "").strip(),
|
||
"title": str(acc.get("title") or "").strip(),
|
||
"role": str(acc.get("role") or "").strip(),
|
||
"email": str(acc.get("email") or "").strip(),
|
||
"job_function": str(
|
||
acc.get("job_function") or acc.get("function") or "",
|
||
).strip(),
|
||
"specialty_user": str(acc.get("specialty") or "").strip(),
|
||
"profile_updated_at": str(acc.get("profile_updated_at") or "").strip(),
|
||
}
|
||
|
||
|
||
def _practice_profile_warnings_payload(
|
||
practice: dict,
|
||
user: Optional[dict],
|
||
license_customer_email: str,
|
||
) -> list[str]:
|
||
w: list[str] = []
|
||
nm = str(practice.get("name") or "").strip()
|
||
if _weak_practice_public_name(nm):
|
||
w.append("Praxisname fehlt oder ist nur Platzhalter")
|
||
if not str(practice.get("specialty") or "").strip():
|
||
w.append("Fachrichtung (Praxis) ist nicht hinterlegt")
|
||
if not str(practice.get("admin_email") or "").strip():
|
||
w.append("Admin-E-Mail der Praxis fehlt")
|
||
if isinstance(user, dict) and user:
|
||
if not str(user.get("display_name") or "").strip():
|
||
w.append("Benutzerprofil: Anzeigename fehlt")
|
||
if not str(user.get("email") or "").strip():
|
||
w.append("Benutzerprofil: E-Mail fehlt")
|
||
if not (
|
||
str(user.get("specialty_user") or "").strip()
|
||
or str(user.get("title") or "").strip()
|
||
):
|
||
w.append("Benutzerprofil: Titel oder Fachrichtung fehlt")
|
||
if (license_customer_email or "").strip() and _weak_practice_public_name(nm):
|
||
w.append(
|
||
"Lizenz-E-Mail ist bekannt, aber das Praxisprofil wirkt unvollstaendig",
|
||
)
|
||
return w
|
||
|
||
|
||
def _entitlements_from_lookup_key(lookup_key: Optional[str]) -> tuple[bool, bool]:
|
||
"""office_allowed, chat_allowed — Rueckwaertscompat: ohne Marker = Office+Chat.
|
||
|
||
Explizite Chat-only-Produkte erkennen wir nur an klar erkennbaren
|
||
lookup_key-Mustern (keineHeuristik aus Namen).
|
||
"""
|
||
lk = (lookup_key or "").strip().lower()
|
||
if not lk:
|
||
return True, True
|
||
chat_only_markers = (
|
||
"chat_only", "chat-only", "aza_chat_only", "empfang_only",
|
||
"minichat_only", "chatonly",
|
||
)
|
||
for m in chat_only_markers:
|
||
if m in lk:
|
||
return False, True
|
||
return True, True
|
||
|
||
|
||
def internal_license_join_existing_practice_account(
|
||
*,
|
||
target_practice_id: str,
|
||
name: str,
|
||
email: str,
|
||
password: str,
|
||
assign_admin: bool,
|
||
license_customer_email: str,
|
||
body: dict,
|
||
) -> dict:
|
||
"""Server-intern (ohne Empfang-API-Token): Konto in **bestehender** Praxis.
|
||
|
||
Wird von ``POST /license/join_existing_practice`` nach erfolgreicher
|
||
Lizenz-Anbindung aufgerufen. Legt keine neue Praxis an und benennt
|
||
bestehende Praxisdaten nicht um.
|
||
"""
|
||
target_practice_id = (target_practice_id or "").strip()
|
||
if not target_practice_id:
|
||
raise HTTPException(status_code=400, detail="Ziel-practice_id fehlt")
|
||
nm = (name or "").strip()
|
||
em = (email or "").strip()
|
||
pw = (password or "").strip()
|
||
if not nm or not em or not pw or len(pw) < 4:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Name, E-Mail und Passwort (min. 4 Zeichen) erforderlich",
|
||
)
|
||
lce = (license_customer_email or "").strip().lower()
|
||
if lce and em.lower() != lce:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="E-Mail stimmt nicht mit der Lizenz-Kundenadresse ueberein.",
|
||
)
|
||
|
||
practices = _load_practices()
|
||
if target_practice_id not in practices:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Praxis zum Einladungscode nicht gefunden oder nicht provisioniert.",
|
||
)
|
||
# Nur sicherstellen, dass Datensatz existiert (ohne Namen zu ueberschreiben).
|
||
_ensure_practice(target_practice_id)
|
||
|
||
accounts = _load_accounts()
|
||
scoped_pr = [a for a in accounts.values() if a.get("practice_id") == target_practice_id]
|
||
target = None
|
||
email_lower = em.lower() if em else ""
|
||
if email_lower:
|
||
email_matches = [
|
||
a for a in scoped_pr
|
||
if (a.get("email") or "").strip().lower() == email_lower
|
||
]
|
||
if len(email_matches) == 1:
|
||
target = email_matches[0]
|
||
if target is None:
|
||
for a in scoped_pr:
|
||
if _normalize_login_username(a.get("display_name") or "") == _normalize_login_username(nm):
|
||
target = a
|
||
break
|
||
|
||
if target:
|
||
pw_hash, pw_salt = _hash_password(pw)
|
||
target["pw_hash"] = pw_hash
|
||
target["pw_salt"] = pw_salt
|
||
if em:
|
||
target["email"] = em
|
||
incoming_dn = nm
|
||
cur_dn_existing = (target.get("display_name") or "").strip()
|
||
if incoming_dn and not cur_dn_existing:
|
||
target["display_name"] = incoming_dn
|
||
if not (target.get("login_name") or "").strip():
|
||
target["login_name"] = _preferred_unique_login_for_display(
|
||
accounts, target_practice_id, nm, str(target.get("user_id") or ""),
|
||
)
|
||
ds = " ".join(
|
||
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
|
||
)
|
||
dt = " ".join(
|
||
(body.get("desktop_title") or body.get("title") or "").strip().split()
|
||
)
|
||
if ds and len(ds) <= 160 and not (str(target.get("specialty") or "").strip()):
|
||
target["specialty"] = ds
|
||
if dt and len(dt) <= 80 and not (str(target.get("title") or "").strip()):
|
||
target["title"] = dt
|
||
if assign_admin:
|
||
r0 = (target.get("role") or "").strip().lower()
|
||
if r0 != "admin":
|
||
target["role"] = "arzt"
|
||
target["admin_source"] = _ADMIN_SOURCE_LICENSE_JOIN_EXISTING
|
||
_save_accounts(accounts)
|
||
practices_snap = _load_practices()
|
||
out: dict = {
|
||
"success": True,
|
||
"user_id": target["user_id"],
|
||
"display_name": target.get("display_name"),
|
||
"role": target.get("role"),
|
||
"admin": _account_has_practice_admin_privileges(target),
|
||
"practice_id": target_practice_id,
|
||
"action": "updated",
|
||
}
|
||
out.update(_auth_provision_profile_attach(practices_snap, target_practice_id, target))
|
||
return out
|
||
|
||
role = "arzt"
|
||
admin_source = _ADMIN_SOURCE_LICENSE_JOIN_EXISTING if assign_admin else ""
|
||
uid = uuid.uuid4().hex[:12]
|
||
pw_hash, pw_salt = _hash_password(pw)
|
||
ln_pv = _preferred_unique_login_for_display(accounts, target_practice_id, nm, "")
|
||
new_account = {
|
||
"user_id": uid,
|
||
"practice_id": target_practice_id,
|
||
"display_name": nm,
|
||
"email": em,
|
||
"login_name": ln_pv,
|
||
"role": role,
|
||
"pw_hash": pw_hash,
|
||
"pw_salt": pw_salt,
|
||
"status": "active",
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
ds2 = " ".join(
|
||
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
|
||
)
|
||
dt2 = " ".join(
|
||
(body.get("desktop_title") or body.get("title") or "").strip().split()
|
||
)
|
||
if ds2 and len(ds2) <= 160:
|
||
new_account["specialty"] = ds2
|
||
if dt2 and len(dt2) <= 80:
|
||
new_account["title"] = dt2
|
||
if admin_source:
|
||
new_account["admin_source"] = admin_source
|
||
accounts[uid] = new_account
|
||
_save_accounts(accounts)
|
||
_log.info(
|
||
"AZA_EMPFANG_LICENSE_JOIN_ACCOUNT practice=%s uid=%s role=%s",
|
||
(target_practice_id or "")[:16], (uid or "")[:16], role,
|
||
)
|
||
practices_snap = _load_practices()
|
||
acc_ref = accounts.get(uid)
|
||
out2: dict = {
|
||
"success": True,
|
||
"user_id": uid,
|
||
"display_name": nm,
|
||
"role": role,
|
||
"admin": _account_has_practice_admin_privileges(acc_ref),
|
||
"practice_id": target_practice_id,
|
||
"action": "created",
|
||
}
|
||
out2.update(_auth_provision_profile_attach(practices_snap, target_practice_id, acc_ref))
|
||
return out2
|
||
|
||
|
||
@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()
|
||
invite_code_in = (body.get("invite_code") 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 = ""
|
||
# Bewusster Beitritt zu einem bestehenden Praxis-Chat per Einladungscode:
|
||
# ueberschreibt X-Practice-Id / practice_id im Body — sonst legt jedes neue
|
||
# Geraet ohne gespeicherte practice_id eine eigene Praxis an (Realbefund).
|
||
if invite_code_in:
|
||
practices = _load_practices()
|
||
want = _invite_code_key(invite_code_in)
|
||
target_pid = None
|
||
for pida, pdata in practices.items():
|
||
if _invite_code_key(pdata.get("invite_code")) == want:
|
||
target_pid = pida
|
||
break
|
||
if not target_pid:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Ungueltiger Chat-Einladungscode — Praxis nicht gefunden.",
|
||
)
|
||
pid = target_pid
|
||
else:
|
||
pid = request.headers.get("X-Practice-Id", "").strip()
|
||
pid = pid or (body.get("practice_id") or "").strip()
|
||
|
||
resolved_from_license = False
|
||
resolved_practice_name = ""
|
||
|
||
if not pid:
|
||
license_key_in = (body.get("license_key") or "").strip()
|
||
cand_list: list[str] = []
|
||
seen_c: set[str] = set()
|
||
try:
|
||
from stripe_routes import (
|
||
list_distinct_practice_ids_for_license_email,
|
||
list_distinct_practice_ids_for_license_key,
|
||
)
|
||
|
||
for p in list_distinct_practice_ids_for_license_email(email):
|
||
p = (p or "").strip()
|
||
if p and p not in seen_c:
|
||
seen_c.add(p)
|
||
cand_list.append(p)
|
||
for p in list_distinct_practice_ids_for_license_key(license_key_in):
|
||
p = (p or "").strip()
|
||
if p and p not in seen_c:
|
||
seen_c.add(p)
|
||
cand_list.append(p)
|
||
except Exception as exc:
|
||
print(f"[EMPFANG] license practice resolution: {exc}")
|
||
|
||
if len(cand_list) > 1:
|
||
practices = _load_practices()
|
||
candidates_payload = []
|
||
for p in cand_list:
|
||
pdata = practices.get(p) or {}
|
||
pname = (pdata.get("name") or "").strip() or p
|
||
candidates_payload.append({"practice_id": p, "practice_name": pname})
|
||
return JSONResponse(content={
|
||
"success": False,
|
||
"step": "choose_practice",
|
||
"message": (
|
||
"Mehrere Praxen passen zu dieser Lizenz bzw. E-Mail. "
|
||
"Bitte waehlen Sie die Praxis aus, der dieses Geraet zugehoeren soll."
|
||
),
|
||
"candidates": candidates_payload,
|
||
})
|
||
if len(cand_list) == 1:
|
||
pid = cand_list[0]
|
||
resolved_from_license = True
|
||
practices = _load_practices()
|
||
pdata = practices.get(pid) or {}
|
||
resolved_practice_name = (pdata.get("name") or "").strip()
|
||
|
||
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 or email:
|
||
practices = _load_practices()
|
||
raw_entry = practices.get(pid)
|
||
entry = dict(raw_entry) if isinstance(raw_entry, dict) else {}
|
||
entry["practice_id"] = pid
|
||
changed_pr = False
|
||
pnm = " ".join((practice_name or "").strip().split())
|
||
cur_nm = str(entry.get("name") or "").strip()
|
||
if pnm and _weak_practice_public_name(cur_nm) and not _weak_practice_public_name(pnm):
|
||
entry["name"] = pnm[:240]
|
||
changed_pr = True
|
||
elif pnm and not _weak_practice_public_name(cur_nm) and pnm != cur_nm:
|
||
# Bewusste Umbenennung gehoert in PATCH /practice/profile, nicht in Provision.
|
||
pass
|
||
if email:
|
||
prev_ae = str(entry.get("admin_email") or "").strip()
|
||
if not prev_ae:
|
||
entry["admin_email"] = email.strip()[:240]
|
||
changed_pr = True
|
||
if changed_pr:
|
||
practices[pid] = entry
|
||
_save_practices(practices)
|
||
|
||
accounts = _load_accounts()
|
||
target = None
|
||
scoped_pr = [a for a in accounts.values() if a.get("practice_id") == pid]
|
||
email_lower = email.lower() if email else ""
|
||
if email_lower:
|
||
email_matches = [
|
||
a for a in scoped_pr
|
||
if (a.get("email") or "").strip().lower() == email_lower
|
||
]
|
||
if len(email_matches) == 1:
|
||
target = email_matches[0]
|
||
if target is None:
|
||
for a in scoped_pr:
|
||
if _normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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
|
||
incoming_dn = (name or "").strip()
|
||
cur_dn_existing = (target.get("display_name") or "").strip()
|
||
if incoming_dn and not cur_dn_existing:
|
||
target["display_name"] = incoming_dn
|
||
if not (target.get("login_name") or "").strip():
|
||
target["login_name"] = _preferred_unique_login_for_display(
|
||
accounts, pid, name, str(target.get("user_id") or ""),
|
||
)
|
||
ds = " ".join(
|
||
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
|
||
)
|
||
dt = " ".join(
|
||
(body.get("desktop_title") or body.get("title") or "").strip().split()
|
||
)
|
||
if ds and len(ds) <= 160 and not (str(target.get("specialty") or "").strip()):
|
||
target["specialty"] = ds
|
||
if dt and len(dt) <= 80 and not (str(target.get("title") or "").strip()):
|
||
target["title"] = dt
|
||
ps_practice = " ".join(
|
||
(
|
||
body.get("practice_specialty")
|
||
or body.get("desktop_practice_specialty")
|
||
or body.get("desktop_specialty")
|
||
or body.get("specialty")
|
||
or ""
|
||
)
|
||
.strip()
|
||
.split()
|
||
)
|
||
if ps_practice and len(ps_practice) <= 160:
|
||
practices_mut = _load_practices()
|
||
ex = practices_mut.get(pid)
|
||
if isinstance(ex, dict) and not (str(ex.get("specialty") or "").strip()):
|
||
ex2 = dict(ex)
|
||
ex2["specialty"] = ps_practice
|
||
practices_mut[pid] = ex2
|
||
_save_practices(practices_mut)
|
||
# STRENGE Admin-Safety bei bestehendem Account in der Zielpraxis:
|
||
# Ein bereits existierendes Konto soll NICHT mehr stillschweigend zum
|
||
# Admin promoviert werden, nur weil aktuell kein Admin in der Praxis
|
||
# existiert. Auto-Admin gilt ausschliesslich beim erstmaligen Bootstrap
|
||
# einer neuen Praxis ueber Lizenzaktivierung (siehe Block unten beim
|
||
# erstmaligen Anlegen eines Accounts). Adminlose Praxen werden ueber
|
||
# /empfang/admin/repair-no-admin oder /empfang/admin/set-admin
|
||
# nachtraeglich versorgt.
|
||
_save_accounts(accounts)
|
||
practices_snap = _load_practices()
|
||
body_out = {
|
||
"success": True, "user_id": target["user_id"],
|
||
"display_name": target["display_name"], "role": target["role"],
|
||
"practice_id": pid,
|
||
"action": "updated",
|
||
}
|
||
body_out.update(_auth_provision_profile_attach(practices_snap, pid, target))
|
||
if resolved_from_license:
|
||
body_out["resolved_existing_practice"] = True
|
||
if resolved_practice_name:
|
||
body_out["resolved_practice_name"] = resolved_practice_name
|
||
return JSONResponse(content=body_out)
|
||
|
||
# STRENGE Auto-Admin-Regel bei NEU angelegtem Account:
|
||
# Admin nur, wenn ALLE Bedingungen erfuellt sind:
|
||
# (1) Die Praxis hat aktuell KEINEN einzigen Account
|
||
# (echter Bootstrap, nicht nur "kein Admin").
|
||
# (2) KEIN invite_code im Request (Beitritts-Pfad ausgeschlossen).
|
||
# (3) license_key wurde mitgeschickt -> bindet den Auto-Admin an
|
||
# die Lizenzaktivierung des Hauptprogramms.
|
||
# Andernfalls bekommt der neue Account `role="arzt"` (nicht Admin).
|
||
# Adminlose Praxen koennen ueber /empfang/admin/repair-no-admin oder
|
||
# /empfang/admin/set-admin durch einen bestehenden Admin (oder durch
|
||
# den ersten legitimen Benutzer) versorgt werden.
|
||
has_admin = any(
|
||
_account_has_practice_admin_privileges(a) and a.get("practice_id") == pid
|
||
for a in accounts.values()
|
||
)
|
||
has_any_account_in_practice = any(
|
||
a.get("practice_id") == pid for a in accounts.values()
|
||
)
|
||
license_key_provided = bool((body.get("license_key") or "").strip())
|
||
bootstrap_admin_allowed = (
|
||
not has_any_account_in_practice
|
||
and not invite_code_in
|
||
and license_key_provided
|
||
)
|
||
if bootstrap_admin_allowed:
|
||
role = "admin"
|
||
admin_source = "license_activation"
|
||
else:
|
||
role = "arzt"
|
||
admin_source = ""
|
||
uid = uuid.uuid4().hex[:12]
|
||
pw_hash, pw_salt = _hash_password(password)
|
||
ln_pv = _preferred_unique_login_for_display(accounts, pid, name, "")
|
||
new_account = {
|
||
"user_id": uid,
|
||
"practice_id": pid,
|
||
"display_name": name,
|
||
"email": email,
|
||
"login_name": ln_pv,
|
||
"role": role,
|
||
"pw_hash": pw_hash,
|
||
"pw_salt": pw_salt,
|
||
"status": "active",
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
ds = " ".join(
|
||
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
|
||
)
|
||
dt = " ".join(
|
||
(body.get("desktop_title") or body.get("title") or "").strip().split()
|
||
)
|
||
if ds and len(ds) <= 160:
|
||
new_account["specialty"] = ds
|
||
if dt and len(dt) <= 80:
|
||
new_account["title"] = dt
|
||
if admin_source:
|
||
new_account["admin_source"] = admin_source
|
||
accounts[uid] = new_account
|
||
_save_accounts(accounts)
|
||
if bootstrap_admin_allowed:
|
||
_log.info(
|
||
"AZA_EMPFANG_ADMIN_BOOTSTRAP practice=%s uid=%s source=license_activation has_admin_before=%s",
|
||
(pid or "")[:16], (uid or "")[:16], int(has_admin),
|
||
)
|
||
else:
|
||
_log.info(
|
||
"AZA_EMPFANG_ACCOUNT_CREATED practice=%s uid=%s role=%s "
|
||
"has_admin=%s had_accounts=%s invite=%s license=%s",
|
||
(pid or "")[:16], (uid or "")[:16], role,
|
||
int(has_admin), int(has_any_account_in_practice),
|
||
int(bool(invite_code_in)), int(license_key_provided),
|
||
)
|
||
body_created = {
|
||
"success": True, "user_id": uid,
|
||
"display_name": name, "role": role,
|
||
"practice_id": pid,
|
||
"action": "created",
|
||
}
|
||
practices_snap = _load_practices()
|
||
acc_ref = accounts.get(uid)
|
||
body_created.update(_auth_provision_profile_attach(practices_snap, pid, acc_ref))
|
||
if resolved_from_license:
|
||
body_created["resolved_existing_practice"] = True
|
||
if resolved_practice_name:
|
||
body_created["resolved_practice_name"] = resolved_practice_name
|
||
return JSONResponse(content=body_created)
|
||
|
||
|
||
@router.post("/auth/forgot_password")
|
||
async def auth_forgot_password(request: Request):
|
||
"""Passwort-Reset mit Benutzername oder E-Mail; kein automatisches Konten-Waehlen bei gleicher E-Mail."""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
raw = (body.get("login") or body.get("email") or body.get("name") 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()
|
||
ambiguous_email_body = {
|
||
"success": False,
|
||
"step": "ambiguous_email",
|
||
"message": (
|
||
"Diese E-Mail ist mehreren Benutzern zugeordnet. "
|
||
"Geben Sie bitte Ihren Benutzernamen ein, damit das richtige Konto eindeutig ist."
|
||
),
|
||
"reset_token_created": False,
|
||
"target_email_masked": "",
|
||
"mail_delivered": False,
|
||
"attempted_delivery": False,
|
||
}
|
||
|
||
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=_forgot_password_neutral_payload())
|
||
if len(matches) == 1:
|
||
return JSONResponse(content=_send_reset_for_account(matches[0]))
|
||
return JSONResponse(content=ambiguous_email_body)
|
||
|
||
if pid:
|
||
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
|
||
matches, _via_forgot = _resolve_browser_login_matches(scoped, raw)
|
||
else:
|
||
matches, _via_forgot = _resolve_browser_login_matches(
|
||
list(accounts.values()), raw,
|
||
)
|
||
if len(matches) == 0:
|
||
return JSONResponse(content=_forgot_password_neutral_payload())
|
||
if len(matches) > 1:
|
||
if pid:
|
||
msg = (
|
||
"Dieser Benutzername ist in dieser Praxis nicht eindeutig. "
|
||
"Bitte verwenden Sie Ihren eindeutigen Login-Namen oder bitten Sie den "
|
||
"Administrator im Hauptfenster, fuer die Konten eindeutige Login-Namen zu setzen."
|
||
)
|
||
step_code = "ambiguous_username_in_practice"
|
||
else:
|
||
msg = (
|
||
"Dieser Benutzername ist ohne gespeicherte Praxis mehrdeutig. "
|
||
"Bitte laden Sie die Seite ueber den Einladungslink der Hauptinstallation "
|
||
"oder geben Sie Ihre gemeinschaftliche E-Mail ein, wenn sie nur einem Konto gilt."
|
||
)
|
||
step_code = "ambiguous_login_no_practice"
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"step": step_code,
|
||
"message": msg,
|
||
"reset_token_created": False,
|
||
"target_email_masked": "",
|
||
"mail_delivered": False,
|
||
"attempted_delivery": False,
|
||
},
|
||
)
|
||
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) > RESET_LINK_TTL_SEC:
|
||
return JSONResponse(
|
||
content={
|
||
"valid": False,
|
||
"detail": "Der Link ist abgelaufen. Bitte neuen Reset anfordern.",
|
||
}
|
||
)
|
||
email_raw = (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,
|
||
"display_name": display_name,
|
||
"email_masked": _mask_email_for_response(email_raw),
|
||
},
|
||
)
|
||
|
||
|
||
@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) > RESET_LINK_TTL_SEC:
|
||
del resets[token]
|
||
_save_json(_DATA_DIR / "empfang_resets.json", resets)
|
||
raise HTTPException(
|
||
status_code=400, detail="Reset-Link ist abgelaufen"
|
||
)
|
||
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]
|
||
dn_hint = (acc.get("display_name") or "").strip()
|
||
ln_saved = (acc.get("login_name") or "").strip()
|
||
em_raw = (acc.get("email") or "").strip()
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"message": "Passwort wurde erfolgreich geändert.",
|
||
"display_name": dn_hint,
|
||
"login_name": ln_saved,
|
||
"email_masked": _mask_email_for_response(em_raw),
|
||
}
|
||
)
|
||
|
||
|
||
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 wählen</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):
|
||
mask = _mask_email_for_response(to_email)
|
||
print(f"[RESET-MAIL] Resend OK -> {mask or '(addr)'}")
|
||
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) -> bool:
|
||
"""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())
|
||
mask = _mask_email_for_response(to_email)
|
||
print(f"[RESET-MAIL] SMTP OK -> {mask or '(addr)'}")
|
||
return True
|
||
except Exception as exc:
|
||
print(f"[RESET-MAIL] SMTP FEHLER: {exc} – versuche Resend …")
|
||
|
||
if _send_reset_via_resend(to_email, subject, text, html):
|
||
return True
|
||
|
||
print(
|
||
"[RESET-MAIL] Weder SMTP noch Resend erfolgreich. "
|
||
"Setzen Sie SMTP_HOST/SMTP_USER/SMTP_PASS oder RESEND_API_KEY (+ MAIL_FROM). "
|
||
"Reset-Link wurde nicht verschickt (Token im Fehlerpfad entsorgt)."
|
||
)
|
||
return False
|
||
|
||
|
||
@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 not _is_admin_session(s):
|
||
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.
|
||
|
||
Zusaetzliche Sicherheit:
|
||
- Last-Admin-Schutz: wenn der Zielbenutzer aktuell Admin ist und auf
|
||
eine Nicht-Admin-Rolle gesetzt werden soll, prueft der Server, ob
|
||
danach noch mindestens ein Admin in derselben Praxis verbleibt.
|
||
- Beim Heraufstufen zu admin wird admin_source="manual_admin_assignment"
|
||
vermerkt; beim Herabstufen wird admin_source geloescht.
|
||
"""
|
||
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")
|
||
target = accounts[user_id]
|
||
if target.get("practice_id") != s["practice_id"]:
|
||
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
|
||
pid = s["practice_id"]
|
||
was_admin = _account_has_practice_admin_privileges(target)
|
||
will_be_admin = (new_role == "admin")
|
||
if was_admin and not will_be_admin:
|
||
admins_now = _admins_for_practice(accounts, pid)
|
||
other_admins = [a for a in admins_now if a.get("user_id") != user_id]
|
||
if not other_admins:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
"Letzter Admin kann nicht entzogen werden. Bitte zuerst "
|
||
"einen anderen Benutzer als Admin festlegen."
|
||
),
|
||
)
|
||
target["role"] = new_role
|
||
if will_be_admin and not was_admin:
|
||
target["admin_source"] = _ADMIN_SOURCE_MANUAL
|
||
target["admin_assigned_by_user_id"] = str(s.get("user_id") or "")
|
||
target["admin_assigned_at"] = _now_z()
|
||
elif was_admin and not will_be_admin:
|
||
target["admin_source"] = ""
|
||
target["admin_revoked_by_user_id"] = str(s.get("user_id") or "")
|
||
target["admin_revoked_at"] = _now_z()
|
||
accounts[user_id] = target
|
||
_save_accounts(accounts)
|
||
_log.info(
|
||
"AZA_EMPFANG_ROLE_CHANGE practice=%s by=%s target=%s role=%s",
|
||
(pid or "")[:16], (s.get("user_id") or "")[:16],
|
||
(user_id or "")[:16], new_role,
|
||
)
|
||
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")
|
||
acc_del = accounts.get(user_id)
|
||
if acc_del and _account_has_practice_admin_privileges(acc_del):
|
||
other_adm = [
|
||
a for a in accounts.values()
|
||
if a.get("practice_id") == s["practice_id"]
|
||
and a.get("user_id") != user_id
|
||
and _account_has_practice_admin_privileges(a)
|
||
]
|
||
if not other_adm:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Letzter Administrator kann nicht geloescht werden.",
|
||
)
|
||
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):
|
||
"""Setzt das Passwort fuer einen Benutzer (Passwort wird im Request mitgeliefert, nur gehasht gespeichert)."""
|
||
s = _require_admin(request)
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
new_pw = (body.get("new_password") or body.get("password") or "").strip()
|
||
if len(new_pw) < 4:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Neues Passwort ist erforderlich (mindestens 4 Zeichen)",
|
||
)
|
||
pid = s["practice_id"]
|
||
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") != pid:
|
||
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
|
||
pw_hash, pw_salt = _hash_password(new_pw)
|
||
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)
|
||
return JSONResponse(
|
||
content={"success": True, "user_id": user_id, "message": "Passwort gespeichert"}
|
||
)
|
||
|
||
|
||
@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)
|
||
# =====================================================================
|
||
|
||
def _attach_devices_to_practice_users(
|
||
users: list, pid: str, *, include_device_identity: bool = False,
|
||
) -> None:
|
||
"""Last aktiv / Presence pro Benutzer; identisch zum frueheren API-Token-Zweig."""
|
||
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", "")
|
||
entry = {
|
||
"device_name": d.get("device_name", ""),
|
||
"platform": d.get("platform", ""),
|
||
"last_active": d.get("last_active", ""),
|
||
"ip_last": d.get("ip_last", ""),
|
||
}
|
||
if include_device_identity and d.get("device_id"):
|
||
entry["device_suffix"] = str(d.get("device_id"))[-8:]
|
||
user_devices.setdefault(uid, []).append(entry)
|
||
for u in users:
|
||
u["devices"] = user_devices.get(u.get("user_id", ""), [])
|
||
|
||
|
||
def _attach_presence_to_practice_users(users: list, pid: str) -> None:
|
||
"""Reichert users_full mit serverseitiger Session-Presence an (RAM, TTL)."""
|
||
pid = (pid or "").strip()
|
||
if not pid:
|
||
return
|
||
for u in users:
|
||
if not isinstance(u, dict):
|
||
continue
|
||
uid = str(u.get("user_id") or "").strip()
|
||
snap = _presence_snapshot_for_user(pid, uid)
|
||
u["presence_online"] = snap["presence_online"]
|
||
u["presence_last_seen"] = snap["presence_last_seen"]
|
||
u["presence_source"] = snap["presence_source"]
|
||
u["presence_age_seconds"] = snap["presence_age_seconds"]
|
||
|
||
|
||
@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"])
|
||
_attach_devices_to_practice_users(users, s["practice_id"])
|
||
_attach_presence_to_practice_users(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:
|
||
_attach_devices_to_practice_users(
|
||
users, pid, include_device_identity=True,
|
||
)
|
||
_attach_presence_to_practice_users(users, pid)
|
||
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 = {}
|
||
action = (body.get("action") or "add").strip()
|
||
|
||
if action == "set_login_name":
|
||
uid_tgt = (body.get("user_id") or "").strip()
|
||
ln_new = " ".join((body.get("login_name") or "").strip().split())
|
||
if not uid_tgt or len(ln_new) < 2:
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "user_id und login_name (mind. 2 Zeichen) erforderlich",
|
||
},
|
||
status_code=400,
|
||
)
|
||
pid_auth = _presence_debug_resolve_practice_auth(request)
|
||
accounts = _load_accounts()
|
||
tgt = accounts.get(uid_tgt)
|
||
if not tgt or (tgt.get("practice_id") or "").strip() != pid_auth:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "Benutzer nicht gefunden"},
|
||
status_code=404,
|
||
)
|
||
if _login_name_conflicts_existing(accounts, pid_auth, ln_new, uid_tgt):
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "Login-Name in dieser Praxis bereits vergeben",
|
||
},
|
||
status_code=409,
|
||
)
|
||
tgt["login_name"] = ln_new
|
||
_save_accounts(accounts)
|
||
return JSONResponse(
|
||
content={"success": True, "user_id": uid_tgt, "login_name": ln_new},
|
||
)
|
||
|
||
if action == "set_password":
|
||
uid_tgt = (body.get("user_id") or "").strip()
|
||
new_pw = (body.get("new_password") or "").strip()
|
||
nw2 = (
|
||
body.get("new_password_repeat")
|
||
or body.get("password2")
|
||
or ""
|
||
).strip()
|
||
ln_req = " ".join(
|
||
(body.get("login_name") or body.get("browser_login_name") or "").strip().split()
|
||
)
|
||
if not uid_tgt or len(new_pw) < 4:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "user_id und Passwort (min. 4) erforderlich"},
|
||
status_code=400,
|
||
)
|
||
if new_pw != nw2:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "Passwoerter stimmen nicht ueberein"},
|
||
status_code=400,
|
||
)
|
||
pid_auth = _presence_debug_resolve_practice_auth(request)
|
||
accounts = _load_accounts()
|
||
tgt = accounts.get(uid_tgt)
|
||
if not tgt or (tgt.get("practice_id") or "").strip() != pid_auth:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "Benutzer nicht gefunden"},
|
||
status_code=404,
|
||
)
|
||
needs_ln = _needs_distinct_login_name(tgt, accounts, pid_auth)
|
||
has_ln = bool((tgt.get("login_name") or "").strip())
|
||
if needs_ln and not has_ln and not ln_req:
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": (
|
||
"Mehrere Konten mit gleichem Anzeigenamen: Bitte zuerst einen "
|
||
"eindeutigen login_name setzen (Feld login_name im gleichen Aufruf "
|
||
"oder action set_login_name)."
|
||
),
|
||
},
|
||
status_code=400,
|
||
)
|
||
if ln_req:
|
||
if len(ln_req) < 2:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "login_name zu kurz"},
|
||
status_code=400,
|
||
)
|
||
prev_nk = _normalize_login_username(
|
||
(tgt.get("login_name") or "").strip()
|
||
)
|
||
req_nk = _normalize_login_username(ln_req)
|
||
if prev_nk != req_nk and _login_name_conflicts_existing(
|
||
accounts, pid_auth, ln_req, uid_tgt,
|
||
):
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "Login-Name in dieser Praxis bereits vergeben",
|
||
},
|
||
status_code=409,
|
||
)
|
||
tgt["login_name"] = ln_req
|
||
pw_hash, pw_salt = _hash_password(new_pw)
|
||
tgt["pw_hash"] = pw_hash
|
||
tgt["pw_salt"] = pw_salt
|
||
tgt.pop("must_change_password", None)
|
||
_save_accounts(accounts)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"user_id": uid_tgt,
|
||
"login_name": (tgt.get("login_name") or "").strip(),
|
||
},
|
||
)
|
||
|
||
if action == "patch_user":
|
||
uid_tgt = (body.get("user_id") or "").strip()
|
||
if not uid_tgt:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "user_id erforderlich"},
|
||
status_code=400,
|
||
)
|
||
dn_new = " ".join((body.get("display_name") or "").strip().split())
|
||
if not dn_new:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "display_name erforderlich"},
|
||
status_code=400,
|
||
)
|
||
pid_auth = _require_practice_admin_or_api_token(request)
|
||
accounts = _load_accounts()
|
||
tgt = accounts.get(uid_tgt)
|
||
if not tgt or (tgt.get("practice_id") or "").strip() != pid_auth:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "Benutzer nicht gefunden"},
|
||
status_code=404,
|
||
)
|
||
tgt["display_name"] = dn_new
|
||
if "login_name" in body:
|
||
ln_req = " ".join((body.get("login_name") or "").strip().split())
|
||
if len(ln_req) < 2:
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "login_name erforderlich (mind. 2 Zeichen)",
|
||
},
|
||
status_code=400,
|
||
)
|
||
prev_nk = _normalize_login_username(
|
||
(tgt.get("login_name") or "").strip()
|
||
)
|
||
req_nk = _normalize_login_username(ln_req)
|
||
if prev_nk != req_nk and _login_name_conflicts_existing(
|
||
accounts, pid_auth, ln_req, uid_tgt,
|
||
):
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "Login-Name in dieser Praxis bereits vergeben",
|
||
},
|
||
status_code=409,
|
||
)
|
||
tgt["login_name"] = ln_req
|
||
if "email" in body:
|
||
raw_em = body.get("email")
|
||
tgt["email"] = (raw_em or "").strip() if isinstance(raw_em, str) else ""
|
||
if "specialty" in body:
|
||
sp = " ".join(str(body.get("specialty") or "").strip().split())
|
||
if len(sp) > 160:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "specialty max. 160 Zeichen"},
|
||
status_code=400,
|
||
)
|
||
tgt["specialty"] = sp
|
||
if "title" in body:
|
||
ti = " ".join(str(body.get("title") or "").strip().split())
|
||
if len(ti) > 80:
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "title max. 80 Zeichen"},
|
||
status_code=400,
|
||
)
|
||
tgt["title"] = ti
|
||
_save_accounts(accounts)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"user_id": uid_tgt,
|
||
"display_name": dn_new,
|
||
"login_name": (tgt.get("login_name") or "").strip(),
|
||
"email": (tgt.get("email") or "").strip(),
|
||
},
|
||
)
|
||
|
||
name = (body.get("name") or "").strip()
|
||
pid = _resolve_practice_id(request)
|
||
if not pid or not name:
|
||
return JSONResponse(content={"success": False})
|
||
accounts = _load_accounts()
|
||
if action == "delete":
|
||
sess = _session_from_request(request)
|
||
actor_name = (body.get("actor_display_name") or "").strip()
|
||
actor_uid = (body.get("actor_user_id") or "").strip()
|
||
target_uid = (body.get("user_id") or "").strip()
|
||
# Bevorzugt: gezielte user_id (verhindert versehentliches Loeschen
|
||
# gleichnamiger Konten). Fallback: Anzeigename-Gruppierung wie bisher.
|
||
if target_uid:
|
||
acc = accounts.get(target_uid)
|
||
if (
|
||
not acc
|
||
or (acc.get("practice_id") or "").strip() != pid
|
||
):
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "Benutzer nicht gefunden"},
|
||
status_code=404,
|
||
)
|
||
to_del = [target_uid]
|
||
else:
|
||
to_del = [
|
||
uid for uid, a in accounts.items()
|
||
if _normalize_login_username(a.get("display_name") or "") == _normalize_login_username(name)
|
||
and a.get("practice_id") == pid
|
||
]
|
||
if not to_del:
|
||
return JSONResponse(content={"success": False, "detail": "Benutzer nicht gefunden"}, status_code=404)
|
||
for uid in list(to_del):
|
||
acc = accounts.get(uid)
|
||
if not acc:
|
||
continue
|
||
if sess and acc.get("user_id") == sess.get("user_id"):
|
||
return JSONResponse(
|
||
content={"success": False, "detail": "Eigenes Konto kann nicht geloescht werden"},
|
||
status_code=400,
|
||
)
|
||
# Selbst-Schutz primaer ueber technische user_id (vermeidet, dass
|
||
# gleichnamige Konten faelschlich als "eigenes Konto" gelten).
|
||
# actor_display_name bleibt nur Fallback, wenn keine actor_user_id
|
||
# mitkommt.
|
||
if actor_uid:
|
||
if str(acc.get("user_id") or "").strip() == actor_uid:
|
||
return JSONResponse(
|
||
content={"success": False,
|
||
"detail": "Eigenes Konto kann nicht geloescht werden"},
|
||
status_code=400,
|
||
)
|
||
elif actor_name and (acc.get("display_name") or "").strip() == actor_name:
|
||
return JSONResponse(
|
||
content={"success": False,
|
||
"detail": "Der aktive Benutzer kann hier nicht geloescht werden"},
|
||
status_code=400,
|
||
)
|
||
if _account_has_practice_admin_privileges(acc):
|
||
others = [
|
||
a for a in accounts.values()
|
||
if a.get("practice_id") == pid
|
||
and a.get("user_id") != uid
|
||
and _account_has_practice_admin_privileges(a)
|
||
]
|
||
if not others:
|
||
return JSONResponse(
|
||
content={"success": False,
|
||
"detail": "Letzter Administrator kann nicht geloescht werden"},
|
||
status_code=400,
|
||
)
|
||
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 (
|
||
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(name)
|
||
and a.get("practice_id") == pid
|
||
):
|
||
a["display_name"] = new_name
|
||
_save_accounts(accounts)
|
||
elif action == "add_secure":
|
||
s = _session_from_request(request)
|
||
sess_role = ""
|
||
pid_ctx = ""
|
||
if s:
|
||
sess_role = str(s.get("role") or "").strip().lower()
|
||
if not (_is_admin_session(s) or sess_role == "empfang"):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Keine Berechtigung Benutzer anzulegen",
|
||
)
|
||
pid_ctx = str(s.get("practice_id") or "").strip()
|
||
elif (request.headers.get("X-API-Token") or "").strip():
|
||
pid_ctx = _presence_debug_resolve_practice_auth(request)
|
||
sess_role = "admin"
|
||
else:
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
if not pid_ctx:
|
||
return JSONResponse(content={"success": False, "detail": "Keine Praxis"}, status_code=400)
|
||
pid = pid_ctx
|
||
pw = (body.get("password") or "").strip()
|
||
pw2 = (body.get("password_repeat") or body.get("password2") or "").strip()
|
||
if pw != pw2:
|
||
return JSONResponse(content={"success": False, "detail": "Passwoerter stimmen nicht ueberein"}, status_code=400)
|
||
if len(pw) < 4:
|
||
return JSONResponse(content={"success": False, "detail": "Passwort mindestens 4 Zeichen"}, status_code=400)
|
||
allowed_roles = {"mpa", "arzt"}
|
||
admin_like = (s and _is_admin_session(s)) or sess_role == "admin"
|
||
if admin_like:
|
||
allowed_roles.update({"admin", "empfang"})
|
||
role_new = str(body.get("role") or "mpa").strip().lower()
|
||
if role_new not in allowed_roles:
|
||
role_new = "mpa"
|
||
exists = any(
|
||
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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(pw)
|
||
ln_hint = body.get("login_name") or body.get("browser_login_name")
|
||
ln_as = " ".join(((ln_hint or "").strip()).split())
|
||
if ln_as:
|
||
if len(ln_as) < 2:
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "login_name zu kurz (mind. 2 Zeichen)",
|
||
},
|
||
status_code=400,
|
||
)
|
||
if _login_name_conflicts_existing(accounts, pid, ln_as, ""):
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "Login-Name in dieser Praxis bereits vergeben",
|
||
},
|
||
status_code=409,
|
||
)
|
||
else:
|
||
ln_as = _preferred_unique_login_for_display(accounts, pid, name, "")
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": pid,
|
||
"display_name": name,
|
||
"role": role_new,
|
||
"login_name": ln_as,
|
||
"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)
|
||
else:
|
||
return JSONResponse(content={"success": False, "detail": "Name bereits vergeben"}, status_code=409)
|
||
else:
|
||
exists = any(
|
||
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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())
|
||
ln_la = _preferred_unique_login_for_display(accounts, pid, name, "")
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": pid,
|
||
"display_name": name,
|
||
"login_name": ln_la,
|
||
"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,
|
||
})
|
||
|
||
|
||
def _require_practice_admin_or_api_token(request: Request) -> str:
|
||
"""Session: nur Admin. API-Token (Desktop) ohne Session gilt als Admin-Kontext."""
|
||
pid = _presence_debug_resolve_practice_auth(request)
|
||
sess = _session_from_request(request)
|
||
if sess and not _is_admin_session(sess):
|
||
raise HTTPException(status_code=403, detail="Nur fuer Praxis-Administratoren")
|
||
return pid
|
||
|
||
|
||
def _audit_login_identifiers_payload(pid: str) -> dict:
|
||
"""Diagnose: keine Hashes/Passwoerter/Tokens — nur Metadaten zur Anmeldung."""
|
||
accounts = _load_accounts()
|
||
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
|
||
|
||
def _row(a: dict) -> dict:
|
||
return {
|
||
"user_id": a.get("user_id"),
|
||
"display_name": a.get("display_name") or "",
|
||
"login_name": (a.get("login_name") or "").strip(),
|
||
"role": a.get("role") or "",
|
||
"status": a.get("status") or "active",
|
||
"has_email": bool((a.get("email") or "").strip()),
|
||
"has_password_hash": bool((a.get("pw_hash") or "").strip()),
|
||
}
|
||
|
||
by_ln: dict[str, list] = defaultdict(list)
|
||
for a in scoped:
|
||
ln = (a.get("login_name") or "").strip()
|
||
if not ln:
|
||
continue
|
||
by_ln[_normalize_login_username(ln)].append(_row(a))
|
||
|
||
dup_ln = [
|
||
{"normalized_key": k, "users": v}
|
||
for k, v in by_ln.items() if len(v) > 1
|
||
]
|
||
|
||
by_dn: dict[str, list] = defaultdict(list)
|
||
for a in scoped:
|
||
dnk = _normalize_login_username(a.get("display_name") or "")
|
||
if dnk:
|
||
by_dn[dnk].append(_row(a))
|
||
|
||
dup_dn = [
|
||
{"normalized_display_key": k, "users": v}
|
||
for k, v in by_dn.items() if len(v) > 1
|
||
]
|
||
|
||
missing_ln = [_row(a) for a in scoped if not (a.get("login_name") or "").strip()]
|
||
|
||
return {
|
||
"practice_id": pid,
|
||
"duplicate_login_name_groups": dup_ln,
|
||
"duplicate_normalized_display_name_groups": dup_dn,
|
||
"users_without_login_name": missing_ln,
|
||
}
|
||
|
||
|
||
@router.get("/admin/login_identifier_audit")
|
||
async def admin_login_identifier_audit(request: Request):
|
||
"""Nur Praxis-Admin oder Desktop-API: Dubletten-Analyse Login/Anzeige."""
|
||
pid = _require_practice_admin_or_api_token(request)
|
||
return JSONResponse(content=_audit_login_identifiers_payload(pid))
|
||
|
||
|
||
@router.get("/admin/forgot_password_probe")
|
||
async def admin_forgot_password_probe(request: Request, login: str = Query("")):
|
||
"""Diagnose-Endpunkt fuer den Passwort-Reset-Flow.
|
||
|
||
Auth: Admin-Session oder gueltiges X-API-Token + X-Practice-Id (gleiche Regel
|
||
wie ``login_identifier_audit``). Gibt nur strukturelle Statusfelder zurueck:
|
||
keine Reset-Tokens, keine vollstaendigen E-Mail-Adressen, keine Passwoerter,
|
||
keine Hashes. Es wird **keine** E-Mail gesendet und **kein** Token erzeugt.
|
||
"""
|
||
pid = _require_practice_admin_or_api_token(request)
|
||
raw = (login or "").strip()
|
||
if not raw:
|
||
return JSONResponse(
|
||
content={
|
||
"success": False,
|
||
"detail": "Query-Parameter 'login' erforderlich",
|
||
},
|
||
status_code=400,
|
||
)
|
||
|
||
accounts = _load_accounts()
|
||
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
|
||
|
||
is_email = _is_likely_email(raw)
|
||
matches: list = []
|
||
match_via = "none"
|
||
if is_email:
|
||
em = _norm_email(raw)
|
||
matches = [
|
||
a for a in scoped
|
||
if _norm_email(a.get("email") or "") == em
|
||
]
|
||
match_via = "email" if matches else "none"
|
||
else:
|
||
matches, match_via = _resolve_browser_login_matches(scoped, raw)
|
||
|
||
target_source = "none"
|
||
has_user_email = False
|
||
has_practice_admin_email = False
|
||
has_license_email = False
|
||
if len(matches) == 1:
|
||
acc = matches[0]
|
||
has_user_email = bool((acc.get("email") or "").strip())
|
||
practices = _load_practices()
|
||
pa_email = ((practices.get(pid) or {}).get("admin_email") or "").strip()
|
||
has_practice_admin_email = bool(pa_email)
|
||
license_email = ""
|
||
try:
|
||
from stripe_routes import lookup_license_email_for_practice
|
||
license_email = (lookup_license_email_for_practice(pid) or "").strip()
|
||
except Exception:
|
||
license_email = ""
|
||
has_license_email = bool(license_email)
|
||
if has_user_email:
|
||
target_source = "user_email"
|
||
elif has_practice_admin_email:
|
||
target_source = "practice_admin_email"
|
||
elif has_license_email:
|
||
target_source = "license_email"
|
||
else:
|
||
target_source = "none"
|
||
|
||
smtp_ok = bool(
|
||
os.environ.get("SMTP_HOST", "").strip()
|
||
and os.environ.get("SMTP_USER", "").strip()
|
||
and os.environ.get("SMTP_PASS", "").strip()
|
||
)
|
||
resend_ok = bool(os.environ.get("RESEND_API_KEY", "").strip())
|
||
mail_provider_configured = (
|
||
"smtp" if smtp_ok else ("resend" if resend_ok else "none")
|
||
)
|
||
|
||
would_attempt_mail = (
|
||
len(matches) == 1
|
||
and target_source != "none"
|
||
and mail_provider_configured != "none"
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"practice_id": pid,
|
||
"input_was_email": is_email,
|
||
"match_count": len(matches),
|
||
"match_via": match_via,
|
||
"target_source": target_source,
|
||
"has_user_email": has_user_email,
|
||
"has_practice_admin_email": has_practice_admin_email,
|
||
"has_license_email": has_license_email,
|
||
"mail_provider_configured": mail_provider_configured,
|
||
"would_attempt_mail": would_attempt_mail,
|
||
"note": (
|
||
"Nur strukturelle Diagnose: keine Tokens, keine Mails, "
|
||
"keine vollstaendigen Adressen."
|
||
),
|
||
})
|
||
|
||
|
||
# =====================================================================
|
||
# 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"]
|
||
|
||
# Strikte Direktchat-Validierung: wenn der Client explizit einen Direktchat
|
||
# markiert (audience=direct ODER recipient_user_id gesetzt) MUSS die
|
||
# Empfaenger-User-ID auf ein Konto in dieser Praxis aufloesbar sein.
|
||
# Verhindert: stille Speicherung als Allgemein, wenn der Browser/Desktop
|
||
# einen Direktchat anzeigt.
|
||
_ex_in = msg.extras or {}
|
||
_aud_in = str(_ex_in.get("audience") or "").strip().lower()
|
||
_ru_in = str(_ex_in.get("recipient_user_id") or "").strip()
|
||
_r_in = str(_ex_in.get("recipient") or "").strip()
|
||
_is_multi_in = (
|
||
isinstance(_ex_in.get("recipients"), list)
|
||
and len(_ex_in.get("recipients") or []) >= 2
|
||
) or ("," in _r_in and _r_in.count(",") >= 1)
|
||
_claims_dm = (_aud_in == "direct") or (_ru_in and not _is_multi_in)
|
||
if _claims_dm:
|
||
_acc = _accounts_by_practice(pid)
|
||
_resolved_ru = _ru_in if _ru_in in _acc else (
|
||
_resolve_user_uid_in_practice(pid, _r_in) if _r_in else ""
|
||
)
|
||
_r_lower = _r_in.lower()
|
||
if not _resolved_ru or _r_lower in ("alle", "all", "allgemein", "an alle"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
"Direktchat konnte technisch nicht eindeutig zugeordnet "
|
||
"werden (recipient_user_id fehlt oder ist ungueltig). "
|
||
"Bitte den Empfaenger erneut auswaehlen."
|
||
),
|
||
)
|
||
_sess_uid_chk = (s.get("user_id") if s else "") or ""
|
||
_claim_su_chk = str(_ex_in.get("sender_user_id") or "").strip()
|
||
if _sess_uid_chk:
|
||
pass
|
||
elif _claim_su_chk and _claim_su_chk in _acc:
|
||
pass
|
||
else:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
"Direktchat: gueltige Session oder sender_user_id fuer "
|
||
"diese Praxis erforderlich."
|
||
),
|
||
)
|
||
|
||
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
|
||
|
||
_ts = _utc_now_iso_z()
|
||
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": _ts,
|
||
"empfangen": _ts,
|
||
"status": "offen",
|
||
"user_id": s["user_id"] if s else "",
|
||
}
|
||
ex_raw = dict(msg.extras or {})
|
||
session_uid_out = (s["user_id"] if s else "") or ""
|
||
entry["extras"] = _enrich_outgoing_direct_extras(pid, absender, ex_raw, session_uid_out)
|
||
|
||
exo_dbg = entry.get("extras") or {}
|
||
if _claims_dm:
|
||
_send_mode = "direct"
|
||
elif _extras_indicates_broadcast(exo_dbg):
|
||
_send_mode = "all"
|
||
else:
|
||
_send_mode = "other"
|
||
_log.info(
|
||
"empfang_send mode=%s practice_id=%s sender_uid=%s recipient_uid=%s "
|
||
"conv_key=%s msg_id=%s",
|
||
_send_mode,
|
||
pid,
|
||
str(exo_dbg.get("sender_user_id") or ""),
|
||
str(exo_dbg.get("recipient_user_id") or ""),
|
||
str(exo_dbg.get("direct_conv_key") or ""),
|
||
msg_id,
|
||
)
|
||
|
||
messages.insert(0, entry)
|
||
_save_messages(messages)
|
||
|
||
try:
|
||
_pulse_bump(pid, sender=absender)
|
||
except Exception:
|
||
pass
|
||
|
||
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})
|
||
|
||
|
||
# =====================================================================
|
||
# CONVERSATION + LIVE-PULSE
|
||
# Eine einzige serverseitige Wahrheit fuer Browser, Hülle und
|
||
# "An Empfang senden". Kein Client-Filter, keine lokale Sonderwahrheit.
|
||
# =====================================================================
|
||
|
||
# In-Memory Pulse: bei jedem POST /send wird der Tick erhoeht.
|
||
# Clients koennen mit kurzen Polls (z. B. 800 ms) auf den Tick lauschen
|
||
# und nur dann die volle Conversation neu holen, wenn der Tick wechselt.
|
||
# Damit wirkt das Signal sofort, ohne traege Sekunden-Lags.
|
||
_PRACTICE_PULSE: dict[str, dict] = {}
|
||
|
||
|
||
def _pulse_bump(practice_id: str, sender: str = ""):
|
||
p = _PRACTICE_PULSE.setdefault(practice_id, {"tick": 0, "ts": 0.0, "last_sender": ""})
|
||
p["tick"] = int(p.get("tick", 0)) + 1
|
||
p["ts"] = time.time()
|
||
p["last_sender"] = sender or ""
|
||
_PRACTICE_PULSE[practice_id] = p
|
||
# Sofortige Benachrichtigung der WebSocket-Clients derselben practice_id.
|
||
# Best-effort: bei fehlendem Event-Loop o.ae. lautlos weiter (Polling-
|
||
# Fallback bleibt aktiv). Keine Patientendaten im Payload.
|
||
try:
|
||
_empfang_ws_notify_pulse(practice_id, int(p["tick"]), float(p["ts"]), str(p["last_sender"]))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _pulse_get(practice_id: str) -> dict:
|
||
p = _PRACTICE_PULSE.get(practice_id)
|
||
if not p:
|
||
# Beim ersten Abruf einen Tick aus den Daten ableiten, damit
|
||
# Clients nach Server-Restart nicht alle "neue Nachricht!" denken.
|
||
msgs = _filter_by_practice(_load_messages(), practice_id)
|
||
latest = ""
|
||
latest_ts = 0.0
|
||
for m in msgs:
|
||
t_raw = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
ts = _parse_msg_instant_utc_ts(t_raw)
|
||
if ts >= latest_ts and t_raw:
|
||
latest_ts = ts
|
||
latest = t_raw
|
||
p = {"tick": 1, "ts": time.time(), "last_sender": "", "boot": latest}
|
||
_PRACTICE_PULSE[practice_id] = p
|
||
return p
|
||
|
||
|
||
# =====================================================================
|
||
# WebSocket-Live-Push (Beschleuniger, KEINE neue Quelle der Wahrheit)
|
||
# =====================================================================
|
||
#
|
||
# Architektur:
|
||
# - Server bleibt autoritativ: /empfang/send schreibt synchron und ruft
|
||
# _pulse_bump() auf. _pulse_bump() benachrichtigt zusaetzlich alle
|
||
# WebSocket-Clients derselben practice_id mit einem {"type":"pulse",...}.
|
||
# - Client behaelt seinen 1-s-Pulse-Poll als Fallback. Bei aktivem WS
|
||
# loest der Server-Push sofortiges loadMessages() aus, ohne den
|
||
# Polling-Tick abzuwarten.
|
||
# - Keine Patientendaten/Chat-Inhalte werden ueber den WS-Kanal
|
||
# gesendet. Nur tick/ts/last_sender als Signal.
|
||
# - Sicherheit: Cookie-Session wird beim WS-Upgrade geprueft. Eine
|
||
# Verbindung erhaelt ausschliesslich Pulses ihrer eigenen practice_id.
|
||
#
|
||
# In-Memory Registrierung. Bei Server-Restart muessen Clients reconnecten
|
||
# (sie tun das automatisch ueber den Backoff).
|
||
|
||
_WS_HEARTBEAT_SECONDS = 20.0
|
||
_WS_PRACTICE_CLIENTS: dict[str, set] = {}
|
||
_WS_LOOP: Optional[asyncio.AbstractEventLoop] = None
|
||
|
||
|
||
def _empfang_ws_register(practice_id: str, ws: "WebSocket") -> None:
|
||
if not practice_id:
|
||
return
|
||
bucket = _WS_PRACTICE_CLIENTS.setdefault(practice_id, set())
|
||
bucket.add(ws)
|
||
|
||
|
||
def _empfang_ws_unregister(practice_id: str, ws: "WebSocket") -> None:
|
||
if not practice_id:
|
||
return
|
||
bucket = _WS_PRACTICE_CLIENTS.get(practice_id)
|
||
if not bucket:
|
||
return
|
||
try:
|
||
bucket.discard(ws)
|
||
except Exception:
|
||
pass
|
||
if not bucket:
|
||
try:
|
||
del _WS_PRACTICE_CLIENTS[practice_id]
|
||
except KeyError:
|
||
pass
|
||
|
||
|
||
async def _empfang_ws_send_safe(ws: "WebSocket", payload: dict) -> bool:
|
||
try:
|
||
await ws.send_json(payload)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def _empfang_ws_broadcast(practice_id: str, payload: dict) -> int:
|
||
"""Schickt ein JSON an alle Clients der Praxis. Liefert Anzahl
|
||
erfolgreicher Sendungen. Bei Fehlern wird der Client entfernt."""
|
||
bucket = list(_WS_PRACTICE_CLIENTS.get(practice_id) or [])
|
||
if not bucket:
|
||
return 0
|
||
dead: list = []
|
||
sent = 0
|
||
for ws in bucket:
|
||
ok = await _empfang_ws_send_safe(ws, payload)
|
||
if ok:
|
||
sent += 1
|
||
else:
|
||
dead.append(ws)
|
||
for ws in dead:
|
||
_empfang_ws_unregister(practice_id, ws)
|
||
return sent
|
||
|
||
|
||
def _empfang_ws_notify_pulse(practice_id: str, tick: int, ts: float, last_sender: str) -> None:
|
||
"""Wird von _pulse_bump() aufgerufen.
|
||
|
||
Best-effort: wenn kein laufender Event-Loop vorhanden ist (Server beim
|
||
Start, Test ohne async), still still und ueberlassen es dem Polling-
|
||
Fallback. Keine Patientendaten im Payload.
|
||
"""
|
||
if not practice_id:
|
||
return
|
||
payload = {
|
||
"type": "pulse",
|
||
"practice_id": practice_id,
|
||
"tick": int(tick),
|
||
"ts": float(ts),
|
||
"last_sender": str(last_sender or ""),
|
||
}
|
||
loop = _WS_LOOP
|
||
if loop is None:
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
except RuntimeError:
|
||
loop = None
|
||
if loop is None or not loop.is_running():
|
||
return
|
||
try:
|
||
asyncio.run_coroutine_threadsafe(
|
||
_empfang_ws_broadcast(practice_id, payload),
|
||
loop,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@router.websocket("/ws")
|
||
async def empfang_ws(websocket: WebSocket):
|
||
"""Live-Pulse-Kanal pro Praxis.
|
||
|
||
Auth via Cookie-Session (aza_session). Verbindung wird an
|
||
practice_id der Session gebunden. Andere Praxen erhalten NIE ein
|
||
Signal aus dieser Connection.
|
||
|
||
Pakettypen (Server -> Client):
|
||
{"type":"hello","tick":N,"ts":t,"practice_id":pid}
|
||
{"type":"pulse","tick":N,"ts":t,"last_sender":""}
|
||
{"type":"ping","ts":t} -- alle ~20 s
|
||
Pakettypen (Client -> Server):
|
||
{"type":"pong"} -- optional, wird nicht erzwungen
|
||
{"type":"hello"} -- optional Initial-Greeting
|
||
|
||
Keine Patientendaten / Chat-Inhalte werden uebertragen. Inhalte holen
|
||
sich Clients weiterhin ueber /empfang/messages.
|
||
"""
|
||
# Auth aus Cookie ziehen (FastAPI/Starlette: websocket.cookies).
|
||
token = ""
|
||
try:
|
||
token = websocket.cookies.get("aza_session") or ""
|
||
except Exception:
|
||
token = ""
|
||
if not token:
|
||
try:
|
||
token = websocket.query_params.get("session_token", "") or ""
|
||
except Exception:
|
||
token = ""
|
||
session = _get_session(token) if token else None
|
||
if not session:
|
||
# 1008 = Policy Violation
|
||
try:
|
||
await websocket.close(code=1008)
|
||
except Exception:
|
||
pass
|
||
return
|
||
pid = (session.get("practice_id") or "").strip()
|
||
uid = (session.get("user_id") or "").strip()
|
||
if not pid:
|
||
try:
|
||
await websocket.close(code=1008)
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
await websocket.accept()
|
||
|
||
# Event-Loop-Referenz fuer _pulse_bump-Broadcast einfangen.
|
||
global _WS_LOOP
|
||
if _WS_LOOP is None:
|
||
try:
|
||
_WS_LOOP = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
_WS_LOOP = None
|
||
|
||
_empfang_ws_register(pid, websocket)
|
||
pulse = _pulse_get(pid)
|
||
_log.info(
|
||
"AZA_EMPFANG_WS_CONNECTED practice=%s uid=%s clients=%d",
|
||
(pid or "")[:16], (uid or "")[:16], len(_WS_PRACTICE_CLIENTS.get(pid, [])),
|
||
)
|
||
|
||
try:
|
||
await _empfang_ws_send_safe(websocket, {
|
||
"type": "hello",
|
||
"practice_id": pid,
|
||
"tick": int(pulse.get("tick", 0)),
|
||
"ts": float(pulse.get("ts", 0.0)),
|
||
"heartbeat_seconds": int(_WS_HEARTBEAT_SECONDS),
|
||
})
|
||
|
||
# Receive-Loop laeuft parallel zu Heartbeat. Beide ueberleben bei
|
||
# Fehler in einer der beiden Tasks.
|
||
async def _heartbeat():
|
||
while True:
|
||
await asyncio.sleep(_WS_HEARTBEAT_SECONDS)
|
||
ok = await _empfang_ws_send_safe(websocket, {
|
||
"type": "ping",
|
||
"ts": time.time(),
|
||
})
|
||
if not ok:
|
||
raise WebSocketDisconnect()
|
||
|
||
async def _recv():
|
||
while True:
|
||
try:
|
||
msg = await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
raise
|
||
# Wir akzeptieren JSON oder beliebigen Text, aber tun
|
||
# nichts daran -- Reaktion bleibt server-getrieben.
|
||
# Verhindert nur, dass Sockets durch fehlendes Receive
|
||
# blockieren.
|
||
if not msg:
|
||
continue
|
||
|
||
hb_task = asyncio.create_task(_heartbeat())
|
||
rx_task = asyncio.create_task(_recv())
|
||
done, pending = await asyncio.wait(
|
||
{hb_task, rx_task},
|
||
return_when=asyncio.FIRST_EXCEPTION,
|
||
)
|
||
for t in pending:
|
||
t.cancel()
|
||
except WebSocketDisconnect:
|
||
pass
|
||
except Exception as exc:
|
||
_log.warning(
|
||
"AZA_EMPFANG_WS_ERROR practice=%s err=%s",
|
||
(pid or "")[:16], type(exc).__name__,
|
||
)
|
||
finally:
|
||
_empfang_ws_unregister(pid, websocket)
|
||
try:
|
||
await websocket.close()
|
||
except Exception:
|
||
pass
|
||
_log.info(
|
||
"AZA_EMPFANG_WS_DISCONNECTED practice=%s uid=%s remaining=%d",
|
||
(pid or "")[:16], (uid or "")[:16],
|
||
len(_WS_PRACTICE_CLIENTS.get(pid, [])),
|
||
)
|
||
|
||
|
||
# =====================================================================
|
||
# Client-Presence (Ping pro angemeldeter Empfang-Instanz, practice-scoped)
|
||
# RAM-beschraenkt wie Pulse; TTL definiert „online“ für /empfang/users.
|
||
# =====================================================================
|
||
|
||
EMPFANG_PRESENCE_TTL_SECONDS = 120
|
||
_PRACTICE_USER_PRESENCE: dict[str, dict] = {}
|
||
|
||
|
||
def _presence_key(pid: str, uid: str) -> str:
|
||
return f"{(pid or '').strip()}|{(uid or '').strip()}"
|
||
|
||
|
||
def _presence_record_ping(pid: str, uid: str, source: str = "web") -> None:
|
||
if not pid or not uid:
|
||
return
|
||
src = ((source or "web").strip() or "web")[:32]
|
||
_PRACTICE_USER_PRESENCE[_presence_key(pid, uid)] = {
|
||
"last_seen": time.time(),
|
||
"source": src,
|
||
}
|
||
|
||
|
||
def _presence_clear_user(pid: str, uid: str) -> None:
|
||
if not pid or not uid:
|
||
return
|
||
_PRACTICE_USER_PRESENCE.pop(_presence_key(pid, uid), None)
|
||
|
||
|
||
def _presence_iso_utc(ts: float) -> str:
|
||
if ts <= 0:
|
||
return ""
|
||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||
"%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
def _presence_snapshot_for_user(pid: str, uid: str) -> dict:
|
||
rec = _PRACTICE_USER_PRESENCE.get(_presence_key(pid, uid))
|
||
now = time.time()
|
||
absent = {
|
||
"presence_online": False,
|
||
"presence_last_seen": None,
|
||
"presence_source": "",
|
||
"presence_age_seconds": None,
|
||
}
|
||
if not rec:
|
||
return absent
|
||
last = float(rec.get("last_seen", 0))
|
||
age = max(0.0, now - last)
|
||
online = age <= float(EMPFANG_PRESENCE_TTL_SECONDS)
|
||
return {
|
||
"presence_online": online,
|
||
"presence_last_seen": _presence_iso_utc(last) if last > 0 else None,
|
||
"presence_source": str(rec.get("source") or ""),
|
||
"presence_age_seconds": int(age),
|
||
}
|
||
|
||
|
||
def _presence_iter_practice(pid: str) -> list[tuple[str, dict]]:
|
||
"""Liefert (user_id, Roh-Eintrag) fuer alle Keys practice_id|user_id."""
|
||
pid = (pid or "").strip()
|
||
rows: list[tuple[str, dict]] = []
|
||
if not pid:
|
||
return rows
|
||
for k, rec in _PRACTICE_USER_PRESENCE.items():
|
||
if not isinstance(rec, dict):
|
||
continue
|
||
parts = str(k).split("|", 1)
|
||
if len(parts) == 2 and parts[0] == pid:
|
||
rows.append((parts[1], rec))
|
||
rows.sort(key=lambda x: x[0])
|
||
return rows
|
||
|
||
|
||
def _presence_count_for_practice(pid: str) -> int:
|
||
return len(_presence_iter_practice(pid))
|
||
|
||
|
||
def _presence_debug_any_device_recent(devs: list, within_sec: int = 120) -> bool:
|
||
"""Vergleicht device last_active grob mit TTL-Logik (wie Frontend <120s)."""
|
||
if not devs:
|
||
return False
|
||
now = time.time()
|
||
for d in devs:
|
||
if not isinstance(d, dict):
|
||
continue
|
||
la = d.get("last_active")
|
||
if la is None or la == "":
|
||
continue
|
||
try:
|
||
s = str(la).strip().replace(" ", "T")
|
||
if s.endswith("Z"):
|
||
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||
else:
|
||
dt = datetime.fromisoformat(s)
|
||
ts = dt.timestamp()
|
||
if now - ts < float(within_sec):
|
||
return True
|
||
except Exception:
|
||
continue
|
||
return False
|
||
|
||
|
||
def _presence_debug_resolve_practice_auth(request: Request) -> str:
|
||
"""practice_id fuer Debug: Session oder validiertes X-API-Token + X-Practice-Id."""
|
||
s = _session_from_request(request)
|
||
if s:
|
||
pid = (s.get("practice_id") or "").strip()
|
||
if not pid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="practice_id in Session fehlt",
|
||
)
|
||
return pid
|
||
api_raw = (request.headers.get("X-API-Token") or "").strip()
|
||
if not api_raw:
|
||
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
|
||
try:
|
||
from aza_security import get_required_api_tokens
|
||
|
||
allowed = get_required_api_tokens()
|
||
except RuntimeError:
|
||
raise HTTPException(status_code=503, detail="API token nicht konfiguriert")
|
||
if not any(hmac.compare_digest(api_raw, t) for t in allowed):
|
||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||
pid = request.headers.get("X-Practice-Id", "").strip()
|
||
if not pid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="X-Practice-Id Header erforderlich",
|
||
)
|
||
return pid
|
||
|
||
|
||
def _norm_name(s: str) -> str:
|
||
"""Vergleichts-String fuer Namen: lower, trim, Akzente ueber NFKD entfernen.
|
||
|
||
Hinweis: ``unicodedata.combining(ch)`` liefert einen ``int`` (0 = kein
|
||
kombinierendes Zeichen). Der Vergleich muss daher gegen ``0`` erfolgen,
|
||
nicht gegen den Leerstring. Andernfalls wuerde der Filter alle Zeichen
|
||
verwerfen und die Funktion stets einen leeren String liefern.
|
||
"""
|
||
t = (s or "").strip().lower()
|
||
t = unicodedata.normalize("NFKD", t)
|
||
return "".join(ch for ch in t if unicodedata.combining(ch) == 0)
|
||
|
||
|
||
def _normalize_login_username(s: str) -> str:
|
||
"""Gleiche Namensform wie in Chat-Routen, zusaetzlich zusammenhaengende Leerzeichen."""
|
||
collapsed = " ".join(((s or "").strip()).split())
|
||
return _norm_name(collapsed)
|
||
|
||
|
||
def _preferred_unique_login_for_display(
|
||
accounts: dict,
|
||
pid: str,
|
||
preferred_raw: str,
|
||
exclude_uid: str = "",
|
||
) -> str:
|
||
"""Bevorzugt menschenlesbaren Namen ohne Suffix-Zufall, wenn pro Praxis (norm.) noch frei."""
|
||
collapsed = " ".join(((preferred_raw or "").strip()).split())
|
||
if len(collapsed) < 2:
|
||
return _allocate_unique_login_name(accounts, pid, preferred_raw or "nutzer")
|
||
if _login_name_conflicts_existing(accounts, pid, collapsed, exclude_uid):
|
||
return _allocate_unique_login_name(accounts, pid, preferred_raw or collapsed)
|
||
return collapsed
|
||
|
||
|
||
def _allocate_unique_login_name(accounts: dict, pid: str, preferred_raw: str) -> str:
|
||
"""Vergibt einen pro Praxis (normalisiert) noch freien login_name."""
|
||
pref = " ".join((preferred_raw or "").strip().split())
|
||
if not pref:
|
||
pref = "nutzer"
|
||
|
||
def _taken(candidate: str) -> bool:
|
||
nk = _normalize_login_username(candidate)
|
||
if not nk:
|
||
return True
|
||
return any(
|
||
(a.get("practice_id") or "").strip() == pid
|
||
and (a.get("login_name") or "").strip()
|
||
and _normalize_login_username((a.get("login_name") or "").strip()) == nk
|
||
for a in accounts.values()
|
||
)
|
||
|
||
for _ in range(48):
|
||
if not _taken(pref):
|
||
return pref
|
||
pref = "%s-%s" % (" ".join((preferred_raw or "").strip().split()) or "nutzer",
|
||
uuid.uuid4().hex[:5])
|
||
base = (" ".join((preferred_raw or "").strip().split()) or "nutzer")
|
||
return f"{base}-{uuid.uuid4().hex[:8]}"
|
||
|
||
|
||
def _needs_distinct_login_name(tgt: dict, accounts: dict, pid: str) -> bool:
|
||
"""True, wenn mindestens ein anderes Konto in der Praxis denselben Anzeigenamen (norm.) hat."""
|
||
nd = _normalize_login_username(tgt.get("display_name") or "")
|
||
if not nd:
|
||
return False
|
||
uid = (tgt.get("user_id") or "").strip()
|
||
n = sum(
|
||
1
|
||
for a in accounts.values()
|
||
if a.get("practice_id") == pid
|
||
and _normalize_login_username(a.get("display_name") or "") == nd
|
||
and (a.get("user_id") or "").strip() != uid
|
||
)
|
||
return n > 0
|
||
|
||
|
||
def _login_name_conflicts_existing(
|
||
accounts: dict,
|
||
pid: str,
|
||
raw_login_name: str,
|
||
exclude_uid: str,
|
||
) -> bool:
|
||
"""True, wenn ein *anderes* Konto in der Praxis denselben Login (normiert) hat.
|
||
|
||
exclude_uid gleicht sowohl Feld user_id als auch den JSON-Schluessel — damit wird
|
||
der eigene Datensatz nie fälschlich als Konflikt gezählt, auch wenn user_id fehlt
|
||
oder abweicht (Legacy-/Import-Artefakte).
|
||
"""
|
||
nk = _normalize_login_username(raw_login_name)
|
||
if not nk:
|
||
return True
|
||
ex = (exclude_uid or "").strip()
|
||
for store_key, a in accounts.items():
|
||
if isinstance(a, dict) and a.get("practice_id") != pid:
|
||
continue
|
||
if not isinstance(a, dict):
|
||
continue
|
||
cand_uid = (str(a.get("user_id") or store_key or "")).strip()
|
||
if ex and cand_uid == ex:
|
||
continue
|
||
ln = (a.get("login_name") or "").strip()
|
||
if not ln:
|
||
continue
|
||
if _normalize_login_username(ln) == nk:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _resolve_browser_login_matches(
|
||
scoped_accounts: list,
|
||
raw_login: str,
|
||
) -> tuple[list, str]:
|
||
"""Login per Benutzername. Liefert IMMER ``(matches, via)``.
|
||
|
||
``via`` ist eines von ``"login_name"``, ``"display_name_fallback"``,
|
||
``"email"`` (nicht hier — der Aufrufer behandelt E-Mail separat) oder
|
||
``"none"``. Konten ohne gesetzten ``login_name`` bleiben rueckwaerts-
|
||
kompatibel ueber den normierten ``display_name`` erreichbar.
|
||
Bei E-Mail-Eingabe wird ``([], "none")`` zurueckgegeben — der Aufrufer
|
||
nutzt dafuer einen separaten E-Mail-Lookup.
|
||
"""
|
||
if _is_likely_email(raw_login):
|
||
return [], "none"
|
||
nk = _normalize_login_username(raw_login)
|
||
if not nk:
|
||
return [], "none"
|
||
|
||
ln_bucket: list = []
|
||
for a in scoped_accounts:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
ln_raw = (a.get("login_name") or "").strip()
|
||
if ln_raw and _normalize_login_username(ln_raw) == nk:
|
||
ln_bucket.append(a)
|
||
if ln_bucket:
|
||
return ln_bucket, "login_name"
|
||
|
||
dn_bucket: list = []
|
||
for a in scoped_accounts:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
if (a.get("login_name") or "").strip():
|
||
continue
|
||
if _normalize_login_username(a.get("display_name") or "") == nk:
|
||
dn_bucket.append(a)
|
||
if dn_bucket:
|
||
return dn_bucket, "display_name_fallback"
|
||
return [], "none"
|
||
|
||
|
||
def _match_accounts_by_login_label(
|
||
scoped_accounts: list, raw_login: str,
|
||
) -> list:
|
||
"""Rueckwaertskompatibilitaet — nur Trefferliste, ohne ``via``-Info."""
|
||
matches, _via = _resolve_browser_login_matches(scoped_accounts, raw_login)
|
||
return matches
|
||
|
||
|
||
def _accounts_by_practice(pid: str) -> dict[str, dict]:
|
||
accounts = _load_accounts()
|
||
return {a["user_id"]: a for a in accounts.values() if a.get("practice_id") == pid}
|
||
|
||
|
||
def _resolve_user_uid_in_practice(pid: str, hint: str) -> str:
|
||
"""Loesst Kurz-ID oder display_name innerhalb einer Praxis zu user_id auf."""
|
||
hint = (hint or "").strip()
|
||
if not hint or not pid:
|
||
return ""
|
||
by_uid = _accounts_by_practice(pid)
|
||
if hint in by_uid:
|
||
return hint
|
||
hn = _normalize_login_username(hint)
|
||
best = ""
|
||
for uid, a in by_uid.items():
|
||
if _normalize_login_username(a.get("display_name") or "") == hn:
|
||
return uid
|
||
return best
|
||
|
||
|
||
def _resolve_user_uid_in_practice_loose(pid: str, hint: str) -> str:
|
||
"""Eindeutige Teilstring-Zuordnung display_name ↔ user_id innerhalb einer Praxis.
|
||
|
||
Nur wenn genau ein Konto matched; sonst leer — keine automatische Fusion.
|
||
Mindestlaenge fuer Hint, um kurze Artefakte wie ``test`` zu vermeiden.
|
||
"""
|
||
hint = (hint or "").strip()
|
||
if not hint or not pid:
|
||
return ""
|
||
ex = _resolve_user_uid_in_practice(pid, hint)
|
||
if ex:
|
||
return ex
|
||
hn = _norm_name(hint)
|
||
if len(hn) < 5:
|
||
return ""
|
||
cand: list[str] = []
|
||
for uid, a in _accounts_by_practice(pid).items():
|
||
dn = _norm_name(a.get("display_name") or "")
|
||
if not dn:
|
||
continue
|
||
if hn in dn or dn in hn:
|
||
cand.append(uid)
|
||
if len(cand) != 1:
|
||
return ""
|
||
return cand[0]
|
||
|
||
|
||
def _resolve_user_uid_unique_last_token(pid: str, full_name_core: str) -> str:
|
||
"""Wenn nach normiertem Nachnamen nur genau ein Konto in der Praxis matched."""
|
||
hn = _norm_name(full_name_core)
|
||
parts = [p for p in hn.split() if p]
|
||
if len(parts) < 2:
|
||
return ""
|
||
last = parts[-1]
|
||
if len(last) < 4:
|
||
return ""
|
||
cand: list[str] = []
|
||
for uid, a in _accounts_by_practice(pid).items():
|
||
dn = _norm_name(a.get("display_name") or "")
|
||
dp = [p for p in dn.split() if p]
|
||
if not dp:
|
||
continue
|
||
if dp[-1] == last:
|
||
cand.append(uid)
|
||
if len(cand) != 1:
|
||
return ""
|
||
return cand[0]
|
||
|
||
|
||
def _extras_indicates_broadcast(ex: dict) -> bool:
|
||
if not isinstance(ex, dict):
|
||
return False
|
||
if ex.get("rcpt_broadcast") in (True, "true", "1", 1):
|
||
return True
|
||
aud = str(ex.get("audience") or "").strip().lower()
|
||
if aud in ("all", "everyone", "broadcast", "general"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _thread_id_stable(m: dict) -> str:
|
||
return str(m.get("thread_id") or m.get("id") or "").strip()
|
||
|
||
|
||
def _thread_requires_broadcast_exclusion(
|
||
messages: list[dict],
|
||
pid: str,
|
||
tid: str,
|
||
) -> bool:
|
||
"""Threads mit klarem (Nicht-)Broadcast-Adressaten nicht unter «An alle» listen."""
|
||
if not tid or not pid:
|
||
return False
|
||
acc_map = _accounts_by_practice(pid)
|
||
for m in messages:
|
||
if _thread_id_stable(m) != tid:
|
||
continue
|
||
if _normalized_group_key_from_message(m):
|
||
continue
|
||
ex = m.get("extras") or {}
|
||
if _extras_indicates_broadcast(ex):
|
||
continue
|
||
su = str(ex.get("sender_user_id") or "").strip()
|
||
ru = str(ex.get("recipient_user_id") or "").strip()
|
||
if su in acc_map and ru in acc_map and su != ru:
|
||
return True
|
||
rcpt_raw = (ex.get("recipient") or "").strip()
|
||
rl = rcpt_raw.lower()
|
||
if rcpt_raw and rl not in ("alle", "all", "allgemein", "an alle"):
|
||
return True
|
||
dk = str(ex.get("direct_conv_key") or "").strip()
|
||
if dk and "|direct|" in dk:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _uid_pair_from_message_for_practice(m: dict, pid: str) -> tuple[str, str]:
|
||
"""Liefert zwei user_ids wenn aus extras + Stammdaten konservativ ableitbar."""
|
||
acc_map = _accounts_by_practice(pid)
|
||
ex = m.get("extras") or {}
|
||
su_ex = str(ex.get("sender_user_id") or "").strip()
|
||
ru_ex = str(ex.get("recipient_user_id") or "").strip()
|
||
su = su_ex if su_ex in acc_map else ""
|
||
ru = ru_ex if ru_ex in acc_map else ""
|
||
|
||
core_s = _sender_core(m.get("absender", ""))
|
||
r_s = (ex.get("recipient") or "").strip()
|
||
rl = r_s.lower()
|
||
if rl in ("alle", "all", "allgemein", "an alle"):
|
||
return ("", "")
|
||
if _extras_indicates_broadcast(ex):
|
||
return ("", "")
|
||
|
||
rs = _resolve_user_uid_in_practice(pid, core_s)
|
||
rr = _resolve_user_uid_in_practice(pid, r_s) if r_s else ""
|
||
|
||
if not rs:
|
||
rs = _resolve_user_uid_in_practice_loose(pid, core_s)
|
||
if not rs:
|
||
rs = _resolve_user_uid_unique_last_token(pid, core_s)
|
||
if not rr and r_s:
|
||
rr = _resolve_user_uid_in_practice_loose(pid, r_s)
|
||
if not rr and r_s:
|
||
rr = _resolve_user_uid_unique_last_token(pid, r_s)
|
||
if not su and rs:
|
||
su = rs
|
||
if not ru and rr:
|
||
ru = rr
|
||
if _normalized_group_key_from_message(m):
|
||
return ("", "")
|
||
if su and ru and su != ru:
|
||
return (su, ru)
|
||
return ("", "")
|
||
|
||
|
||
def _direct_conv_key(pid: str, uid_a: str, uid_b: str) -> str:
|
||
ua, ub = sorted([uid_a, uid_b])
|
||
return f"{pid}|direct|{ua}|{ub}"
|
||
|
||
|
||
def _sender_core(absender: str) -> str:
|
||
"""Aus 'Vorname Nachname (HOST)' -> 'Vorname Nachname'."""
|
||
s = (absender or "").split("(")[0].strip()
|
||
return s
|
||
|
||
|
||
def _enrich_outgoing_direct_extras(pid: str, absender: str, extras: dict,
|
||
session_uid: str) -> dict:
|
||
"""DM: sender_user_id, recipient_user_id, direct_conv_key (stabil)."""
|
||
ex = dict(extras or {})
|
||
recipient_raw = (ex.get("recipient") or "").strip()
|
||
rlist = ex.get("recipients")
|
||
is_multi = (
|
||
isinstance(rlist, list) and len(rlist) >= 2
|
||
) or ("," in recipient_raw and recipient_raw.count(",") >= 1)
|
||
broadcast_rcpt = not recipient_raw or recipient_raw.lower() in ("alle", "all", "allgemein")
|
||
if broadcast_rcpt or is_multi:
|
||
return ex
|
||
|
||
by_uid = _accounts_by_practice(pid)
|
||
core = _sender_core(absender)
|
||
|
||
sender_uid = (session_uid or "").strip()
|
||
if not sender_uid:
|
||
su_claim = str(ex.get("sender_user_id") or "").strip()
|
||
resolved = _resolve_user_uid_in_practice(pid, core)
|
||
if su_claim and su_claim in by_uid and su_claim == resolved:
|
||
sender_uid = su_claim
|
||
elif su_claim and su_claim in by_uid and not resolved:
|
||
sender_uid = su_claim
|
||
else:
|
||
sender_uid = resolved
|
||
else:
|
||
if sender_uid not in by_uid:
|
||
fb = _resolve_user_uid_in_practice(pid, core)
|
||
if fb:
|
||
sender_uid = fb
|
||
|
||
if sender_uid:
|
||
ex["sender_user_id"] = sender_uid
|
||
|
||
recipient_uid = _resolve_user_uid_in_practice(pid, recipient_raw)
|
||
ru_claim = str(ex.get("recipient_user_id") or "").strip()
|
||
if ru_claim and ru_claim in by_uid:
|
||
if not recipient_uid or ru_claim == recipient_uid:
|
||
recipient_uid = ru_claim
|
||
|
||
if recipient_uid:
|
||
ex["recipient_user_id"] = recipient_uid
|
||
|
||
if sender_uid and recipient_uid:
|
||
ex["direct_conv_key"] = _direct_conv_key(pid, sender_uid, recipient_uid)
|
||
|
||
# Explizites DM-Tagging: garantiert, dass diese Nachricht nicht als
|
||
# Allgemein-/Broadcast-Inbox-Treffer ausgewertet wird.
|
||
if recipient_raw and not is_multi:
|
||
ex["audience"] = "direct"
|
||
ex["rcpt_broadcast"] = False
|
||
|
||
return ex
|
||
|
||
|
||
def _msg_recipient(m: dict) -> str:
|
||
extras = m.get("extras") or {}
|
||
return (extras.get("recipient") or "").strip()
|
||
|
||
|
||
def _normalized_group_key_from_extras(extras: dict) -> str:
|
||
"""Canonical key 'name|name|...' lowercase for multi-recipient threads."""
|
||
if not isinstance(extras, dict):
|
||
return ""
|
||
rlist = extras.get("recipients")
|
||
if isinstance(rlist, list) and len(rlist) >= 2:
|
||
parts = sorted({_norm_name(str(x)) for x in rlist if str(x).strip()})
|
||
return "|".join(parts) if parts else ""
|
||
rcpt = (extras.get("recipient") or "").strip()
|
||
if "," in rcpt:
|
||
parts = sorted({_norm_name(p) for p in rcpt.split(",") if p.strip()})
|
||
if len(parts) >= 2:
|
||
return "|".join(parts)
|
||
return ""
|
||
|
||
|
||
def _normalized_group_key_from_message(m: dict) -> str:
|
||
return _normalized_group_key_from_extras(m.get("extras") or {})
|
||
|
||
|
||
def _dm_message_matches_pair(m: dict, me_n: str, peer_n: str) -> bool:
|
||
"""True, wenn Nachricht zum 1:1-Paar (mit display_name-normalisierten Kernen) gehoert."""
|
||
if _normalized_group_key_from_message(m):
|
||
return False
|
||
sender_n = _norm_name(_sender_core(m.get("absender", "")))
|
||
rcpt_n = _norm_name(_msg_recipient(m))
|
||
ex = m.get("extras") or {}
|
||
has_reply = bool(str(ex.get("reply_to") or "").strip())
|
||
|
||
if me_n and peer_n:
|
||
if sender_n and sender_n not in (me_n, peer_n):
|
||
return False
|
||
if rcpt_n in ("", "alle"):
|
||
# Leerer Empfaenger: nur echte Thread-Antwort (kein Rundschreiben ohne Adresse).
|
||
return has_reply and bool(sender_n) and sender_n in (me_n, peer_n)
|
||
if rcpt_n not in (me_n, peer_n):
|
||
return False
|
||
return (
|
||
(sender_n == me_n and rcpt_n == peer_n)
|
||
or (sender_n == peer_n and rcpt_n == me_n)
|
||
)
|
||
|
||
# Wenig 'me' vom Client: sehr konservativ, keine Rundmails ohne reply_to reinziehen.
|
||
if not peer_n:
|
||
return False
|
||
if rcpt_n in ("", "alle"):
|
||
return has_reply and sender_n == peer_n
|
||
if sender_n == peer_n and rcpt_n and rcpt_n not in ("alle",) and rcpt_n != peer_n:
|
||
return True
|
||
if rcpt_n == peer_n and sender_n and sender_n != peer_n:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _msg_by_id_index(messages: list[dict]) -> dict[str, dict]:
|
||
return {str(m.get("id")): m for m in messages if m.get("id")}
|
||
|
||
|
||
def _thread_root_msg(m: dict, by_id: dict[str, dict]) -> Optional[dict]:
|
||
cur: Optional[dict] = m
|
||
steps = 0
|
||
while cur is not None and steps < 500:
|
||
steps += 1
|
||
rto = str((cur.get("extras") or {}).get("reply_to") or "").strip()
|
||
if not rto:
|
||
return cur
|
||
nxt = by_id.get(rto)
|
||
if nxt is None:
|
||
return cur
|
||
cur = nxt
|
||
return cur
|
||
|
||
|
||
def _root_is_broadcast_inbox(root: Optional[dict], pid: str = "") -> bool:
|
||
if not root:
|
||
return False
|
||
if _normalized_group_key_from_message(root):
|
||
return False
|
||
pid = (pid or "").strip()
|
||
exroot = root.get("extras") or {}
|
||
if pid and isinstance(exroot, dict) and not _extras_indicates_broadcast(exroot):
|
||
acc_map = _accounts_by_practice(pid)
|
||
su = str(exroot.get("sender_user_id") or "").strip()
|
||
ru = str(exroot.get("recipient_user_id") or "").strip()
|
||
if su in acc_map and ru in acc_map and su != ru:
|
||
return False
|
||
rcpt_n = _norm_name(_msg_recipient(root))
|
||
return rcpt_n in ("", "alle")
|
||
|
||
|
||
def _dm_extras_uid_symmetric_match(
|
||
ex: dict, me_uid: str, peer_uid: str, acc_map: dict[str, dict]
|
||
) -> bool:
|
||
su = str(ex.get("sender_user_id") or "").strip()
|
||
ru = str(ex.get("recipient_user_id") or "").strip()
|
||
if su not in acc_map or ru not in acc_map or su == ru:
|
||
return False
|
||
return {su, ru} == {me_uid, peer_uid}
|
||
|
||
|
||
def _dm_uid_pair_matches_message(m: dict, pid: str, me_uid: str, peer_uid: str) -> bool:
|
||
"""True, wenn aus Absender-/Empfaenger-/extras eindeutig dasselbe 1:1-Paar wird."""
|
||
if not me_uid or not peer_uid:
|
||
return False
|
||
a, b = _uid_pair_from_message_for_practice(m, pid)
|
||
return bool(a and b and {a, b} == {me_uid, peer_uid})
|
||
|
||
|
||
def _conversation_dm_by_key_or_names(
|
||
messages: list[dict],
|
||
pid: str,
|
||
me_uid: str,
|
||
peer_uid: str,
|
||
me_display: str,
|
||
peer_display_fallback: str,
|
||
) -> list[dict]:
|
||
acc_map = _accounts_by_practice(pid)
|
||
peer_dn = peer_display_fallback
|
||
if peer_uid and peer_uid in acc_map:
|
||
peer_dn = (acc_map[peer_uid].get("display_name") or "").strip() or peer_dn
|
||
me_dn = me_display
|
||
if me_uid and me_uid in acc_map:
|
||
me_dn = (acc_map[me_uid].get("display_name") or "").strip() or me_dn
|
||
|
||
me_n = _norm_name(me_dn)
|
||
peer_n = _norm_name(peer_dn)
|
||
key_need = ""
|
||
if me_uid and peer_uid:
|
||
key_need = _direct_conv_key(pid, me_uid, peer_uid)
|
||
|
||
out: list[dict] = []
|
||
for m in messages:
|
||
if _normalized_group_key_from_message(m):
|
||
continue
|
||
ex = m.get("extras") or {}
|
||
rcpt_raw_l = (ex.get("recipient") or "").strip().lower()
|
||
if rcpt_raw_l in ("alle", "all", "allgemein", "an alle"):
|
||
continue
|
||
if _extras_indicates_broadcast(ex):
|
||
continue
|
||
|
||
matched = False
|
||
if key_need:
|
||
km = str(ex.get("direct_conv_key") or "").strip()
|
||
if km == key_need:
|
||
matched = True
|
||
elif _dm_extras_uid_symmetric_match(ex, me_uid, peer_uid, acc_map):
|
||
matched = True
|
||
elif km and km != key_need:
|
||
# Alter/falscher Key: nur bei Nachweis desselben Teilnehmerpaares
|
||
# oder konservativem Legacy-Namenmatch zulassen (nicht fremdes DM).
|
||
if _dm_uid_pair_matches_message(m, pid, me_uid, peer_uid):
|
||
matched = True
|
||
elif me_n and peer_n and _dm_message_matches_pair(m, me_n, peer_n):
|
||
matched = True
|
||
else:
|
||
continue
|
||
|
||
if not matched and me_uid and peer_uid:
|
||
if _dm_uid_pair_matches_message(m, pid, me_uid, peer_uid):
|
||
matched = True
|
||
|
||
if not matched and me_n and peer_n and _dm_message_matches_pair(m, me_n, peer_n):
|
||
matched = True
|
||
|
||
if matched:
|
||
out.append(m)
|
||
return out
|
||
|
||
|
||
def _conversation_for_audience(
|
||
messages: list[dict],
|
||
practice_id_scope: str,
|
||
me_display: str,
|
||
audience: str,
|
||
me_user_id: str = "",
|
||
peer_user_id: str = "",
|
||
) -> list[dict]:
|
||
"""
|
||
Audience-Modell:
|
||
- Optional me_user_id + peer_user_id: stabiler Direktchat (direct_conv_key).
|
||
- Sonst Fallback ueber normierte display_name-Paare.
|
||
"""
|
||
aud_raw = (audience or "").strip()
|
||
aud_lower = aud_raw.lower()
|
||
if aud_lower in ("__noop__", "__multi__"):
|
||
return []
|
||
|
||
is_broadcast = aud_lower in ("", "alle", "all", "allgemein")
|
||
|
||
pid = (practice_id_scope or "").strip()
|
||
|
||
def _tid(msg: dict) -> str:
|
||
return str(msg.get("thread_id") or msg.get("id") or "")
|
||
|
||
out: list[dict] = []
|
||
|
||
if is_broadcast:
|
||
by_id = _msg_by_id_index(messages)
|
||
for m in messages:
|
||
if _normalized_group_key_from_message(m):
|
||
continue
|
||
rcpt_n = _norm_name(_msg_recipient(m))
|
||
if rcpt_n not in ("", "alle"):
|
||
continue
|
||
root = _thread_root_msg(m, by_id)
|
||
if not _root_is_broadcast_inbox(root, pid):
|
||
continue
|
||
tid = _tid(m)
|
||
if _thread_requires_broadcast_exclusion(messages, pid, tid):
|
||
continue
|
||
out.append(m)
|
||
out.sort(key=_msg_chrono_sort_key)
|
||
return out
|
||
|
||
# --- Gruppen-Chat ---
|
||
if aud_lower.startswith("group|"):
|
||
target_key = aud_lower[len("group|"):].strip()
|
||
participants = {_norm_name(p) for p in target_key.split("|") if p.strip()}
|
||
thread_ids: set[str] = set()
|
||
for m in messages:
|
||
gk = _normalized_group_key_from_message(m)
|
||
if gk == target_key:
|
||
thread_ids.add(_tid(m))
|
||
for m in messages:
|
||
if _tid(m) not in thread_ids:
|
||
continue
|
||
gk2 = _normalized_group_key_from_message(m)
|
||
if gk2 == target_key:
|
||
out.append(m)
|
||
continue
|
||
if not gk2:
|
||
sn = _norm_name(_sender_core(m.get("absender", "")))
|
||
if sn and sn in participants:
|
||
out.append(m)
|
||
out.sort(key=_msg_chrono_sort_key)
|
||
return out
|
||
|
||
# --- 1:1 Direktverlauf ---
|
||
mu = (me_user_id or "").strip() or ""
|
||
pu = (peer_user_id or "").strip() or _resolve_user_uid_in_practice(pid, aud_raw)
|
||
|
||
dm_list = _conversation_dm_by_key_or_names(
|
||
messages,
|
||
pid,
|
||
mu,
|
||
pu,
|
||
me_display,
|
||
aud_raw,
|
||
)
|
||
out.extend(dm_list)
|
||
out.sort(key=_msg_chrono_sort_key)
|
||
return out
|
||
|
||
|
||
@router.get("/conversation")
|
||
async def empfang_conversation(
|
||
request: Request,
|
||
audience: str = Query(""),
|
||
me: str = Query(""),
|
||
me_user_id: str = Query(""),
|
||
peer_user_id: str = Query(""),
|
||
practice_id: Optional[str] = Query(None),
|
||
):
|
||
"""Liefert den vollstaendigen, serverseitig gefilterten Verlauf.
|
||
Eine Quelle fuer Browser, Hülle und Desktop-Dialog "An Empfang senden".
|
||
Optional: me_user_id + peer_user_id fuer stabilen Direktchat (gleiche Logik ueberall).
|
||
"""
|
||
pid = (practice_id or "").strip() or _resolve_practice_id(request)
|
||
if not pid:
|
||
return JSONResponse(
|
||
content={"success": True, "messages": [], "tick": 0},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
s = _session_from_request(request)
|
||
me_eff = (me or "").strip() or (s.get("display_name") if s else "")
|
||
me_uid_eff = (me_user_id or "").strip() or (str(s.get("user_id") or "").strip() if s else "")
|
||
if not me_uid_eff and me_eff:
|
||
# Fallback: aus dem Anzeigename in der Praxis aufloesen, damit
|
||
# Desktop ohne Browser-Session denselben direct_conv_key trifft.
|
||
me_uid_eff = _resolve_user_uid_in_practice(pid, me_eff)
|
||
peer_uid_eff = (peer_user_id or "").strip()
|
||
if not peer_uid_eff:
|
||
aud_raw = (audience or "").strip()
|
||
aud_lower = aud_raw.lower()
|
||
if aud_raw and aud_lower not in ("", "alle", "all", "allgemein", "__noop__", "__multi__") and not aud_lower.startswith("group|"):
|
||
peer_uid_eff = _resolve_user_uid_in_practice(pid, aud_raw)
|
||
messages = _filter_by_practice(_load_messages(), pid)
|
||
conv = _conversation_for_audience(
|
||
messages,
|
||
pid,
|
||
me_eff,
|
||
audience,
|
||
me_uid_eff,
|
||
peer_uid_eff,
|
||
)
|
||
pulse = _pulse_get(pid)
|
||
_log.info(
|
||
"empfang_conversation load practice_id=%s me_uid=%s peer_uid=%s "
|
||
"audience_key=%s msg_count=%s tick=%s",
|
||
pid,
|
||
me_uid_eff,
|
||
peer_uid_eff,
|
||
(audience or "")[:80] if audience else "",
|
||
len(conv),
|
||
int(pulse.get("tick", 0)),
|
||
)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"messages": conv,
|
||
"audience": audience or "",
|
||
"me": me_eff,
|
||
"me_user_id": me_uid_eff,
|
||
"peer_user_id_used": peer_uid_eff,
|
||
"tick": int(pulse.get("tick", 0)),
|
||
"ts": pulse.get("ts", 0.0),
|
||
},
|
||
headers={
|
||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||
"Pragma": "no-cache",
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/pulse")
|
||
async def empfang_pulse(request: Request, practice_id: Optional[str] = Query(None)):
|
||
"""Sehr leichter Endpoint fuer Live-Pulse.
|
||
Clients pollen kurz (z. B. 800 ms) und holen die Conversation nur dann
|
||
neu, wenn sich 'tick' geaendert hat. Damit erscheint das Signal sofort
|
||
und ohne 5–10 s Verzoegerung der alten Polling-Loop.
|
||
"""
|
||
pid = (practice_id or "").strip() or _resolve_practice_id(request)
|
||
if not pid:
|
||
return JSONResponse(
|
||
content={"tick": 0, "ts": 0.0},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
p = _pulse_get(pid)
|
||
tick = int(p.get("tick", 0))
|
||
content: dict = {
|
||
"tick": tick,
|
||
"ts": float(p.get("ts", 0.0)),
|
||
"last_sender": p.get("last_sender", ""),
|
||
}
|
||
try:
|
||
s = _session_from_request(request)
|
||
if s:
|
||
me_uid = str(s.get("user_id") or "").strip()
|
||
if me_uid:
|
||
snap = _pulse_dm_pending_ack_for_tick(pid, me_uid, tick)
|
||
content["dm_pending_ack_by_peer"] = snap["by_peer"]
|
||
content["dm_pending_ack_total"] = int(snap["total"])
|
||
except Exception:
|
||
pass
|
||
return JSONResponse(
|
||
content=content,
|
||
headers={
|
||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||
"Pragma": "no-cache",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/presence/ping")
|
||
async def empfang_presence_ping(request: Request):
|
||
"""Setzt fuer die aktuelle Praxis/User-Kombination last_seen (TTL = online).
|
||
|
||
Cookie-Session: Web / WebView. Optional: Desktop mit X-API-Token +
|
||
X-Practice-Id + X-AzA-Empfang-User-Id (gleiche Pruefung wie Shell-Erzeugung).
|
||
"""
|
||
now = time.time()
|
||
ttl = int(EMPFANG_PRESENCE_TTL_SECONDS)
|
||
s = _session_from_request(request)
|
||
if s:
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if pid and uid:
|
||
_presence_record_ping(pid, uid, "web")
|
||
cnt = _presence_count_for_practice(pid)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"server_time": now,
|
||
"ttl_seconds": ttl,
|
||
"own_user_id": uid,
|
||
"practice_id": pid,
|
||
"presence_count_for_practice": cnt,
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
api_raw = (request.headers.get("X-API-Token") or "").strip()
|
||
if api_raw:
|
||
pid, uid = _require_shell_api_identity(request)
|
||
_presence_record_ping(pid, uid, "desktop")
|
||
cnt = _presence_count_for_practice(pid)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"server_time": now,
|
||
"ttl_seconds": ttl,
|
||
"own_user_id": uid,
|
||
"practice_id": pid,
|
||
"presence_count_for_practice": cnt,
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
|
||
|
||
@router.get("/presence/debug")
|
||
async def empfang_presence_debug(request: Request):
|
||
"""Diagnose: RAM-Presence + Projektion wie /users (nur Auth, keine Patientendaten)."""
|
||
pid = _presence_debug_resolve_practice_auth(request)
|
||
ttl = int(EMPFANG_PRESENCE_TTL_SECONDS)
|
||
now_ts = time.time()
|
||
now_iso = _presence_iso_utc(now_ts)
|
||
accounts = _accounts_by_practice(pid)
|
||
|
||
presence_store: list[dict] = []
|
||
for uid_store, rec in _presence_iter_practice(pid):
|
||
last = float(rec.get("last_seen", 0))
|
||
age_s = max(0, int(now_ts - last))
|
||
online = age_s <= ttl
|
||
acc = accounts.get(uid_store, {})
|
||
presence_store.append({
|
||
"user_id": uid_store,
|
||
"display_name": str(acc.get("display_name") or ""),
|
||
"role": str(acc.get("role") or ""),
|
||
"last_seen": _presence_iso_utc(last) if last > 0 else "",
|
||
"age_seconds": age_s,
|
||
"online": online,
|
||
"source": str(rec.get("source") or ""),
|
||
})
|
||
|
||
users = _practice_users(pid)
|
||
_attach_devices_to_practice_users(users, pid)
|
||
_attach_presence_to_practice_users(users, pid)
|
||
users_projection: list[dict] = []
|
||
for u in users:
|
||
devs = u.get("devices") or []
|
||
users_projection.append({
|
||
"user_id": str(u.get("user_id") or ""),
|
||
"display_name": str(u.get("display_name") or ""),
|
||
"role": str(u.get("role") or ""),
|
||
"presence_online": bool(u.get("presence_online")),
|
||
"presence_age_seconds": u.get("presence_age_seconds"),
|
||
"presence_source": str(u.get("presence_source") or ""),
|
||
"has_devices": len(devs) > 0,
|
||
"device_recent": _presence_debug_any_device_recent(devs),
|
||
})
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"practice_id": pid,
|
||
"now": now_iso,
|
||
"ttl_seconds": ttl,
|
||
"presence_store": presence_store,
|
||
"users_projection": users_projection,
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
|
||
|
||
# =====================================================================
|
||
# DM v2 — Direct-Only, Fail-Closed
|
||
# Eigene, isolierte API ausschliesslich fuer Personenchats:
|
||
# - kein Allgemein/Broadcast-Pfad
|
||
# - kein Namens-/Heuristik-Matching
|
||
# - Speicherung und Laden nur ueber direct_conv_key
|
||
# - Verifizierbar (msg_id + conversation_key in Antwort)
|
||
# =====================================================================
|
||
|
||
|
||
class DmSendIn(BaseModel):
|
||
practice_id: str = ""
|
||
sender_user_id: str = ""
|
||
recipient_user_id: str = ""
|
||
text: str = ""
|
||
attachments: list = Field(default_factory=list)
|
||
attachment_ids: list[str] = Field(default_factory=list)
|
||
client_msg_id: str = ""
|
||
|
||
|
||
def _dm_v2_load_for_pair(pid: str, uid_a: str, uid_b: str) -> tuple[list[dict], str]:
|
||
"""Liefert genau die Nachrichten dieses 1:1-Schluessels, sortiert chronologisch."""
|
||
conv_key = _direct_conv_key(pid, uid_a, uid_b)
|
||
msgs = _filter_by_practice(_load_messages(), pid)
|
||
out: list[dict] = []
|
||
for m in msgs:
|
||
ex = m.get("extras") or {}
|
||
if str(ex.get("direct_conv_key") or "").strip() == conv_key:
|
||
out.append(m)
|
||
out.sort(key=_msg_chrono_sort_key)
|
||
return out, conv_key
|
||
|
||
|
||
def _compute_dm_pending_ack_by_peer(pid: str, me_uid: str) -> dict[str, int]:
|
||
"""Eingehende DM an mich ohne chat_ack: Zähler pro Absender-user_id (für Badges / Alarm).
|
||
|
||
Nutzt dieselben Felder wie /dm/conversation (direct_conv_key, sender/recipient_user_id).
|
||
"""
|
||
by_peer: dict[str, int] = {}
|
||
if not pid or not me_uid:
|
||
return by_peer
|
||
for m in _filter_by_practice(_load_messages(), pid):
|
||
ex = m.get("extras") or {}
|
||
if not str(ex.get("direct_conv_key") or "").strip():
|
||
continue
|
||
su = str(ex.get("sender_user_id") or "").strip()
|
||
ru = str(ex.get("recipient_user_id") or "").strip()
|
||
if not su or su == me_uid:
|
||
continue
|
||
if ru != me_uid:
|
||
continue
|
||
if bool(ex.get("chat_ack")):
|
||
continue
|
||
by_peer[su] = by_peer.get(su, 0) + 1
|
||
return by_peer
|
||
|
||
|
||
_PENDING_ACK_CACHE_MAX = 128
|
||
_DM_PENDING_ACK_CACHE: dict[str, dict] = {}
|
||
|
||
|
||
def _pulse_dm_pending_ack_for_tick(pid: str, me_uid: str, tick: int) -> dict:
|
||
"""Pro (Praxis, Nutzer, Pulse-Tick) einmal berechnen, dann cachen (Tick bump = neue Daten)."""
|
||
key = f"{pid}|{me_uid}|{tick}"
|
||
hit = _DM_PENDING_ACK_CACHE.get(key)
|
||
if hit is not None:
|
||
return hit
|
||
by_peer = _compute_dm_pending_ack_by_peer(pid, me_uid)
|
||
out = {
|
||
"by_peer": by_peer,
|
||
"total": int(sum(by_peer.values())),
|
||
}
|
||
if len(_DM_PENDING_ACK_CACHE) > _PENDING_ACK_CACHE_MAX:
|
||
_DM_PENDING_ACK_CACHE.clear()
|
||
_DM_PENDING_ACK_CACHE[key] = out
|
||
return out
|
||
|
||
|
||
@router.post("/dm/send")
|
||
async def empfang_dm_send(payload: DmSendIn, request: Request):
|
||
"""Direct-Only Senden. Fail-Closed:
|
||
- practice_id Pflicht (Body oder Session)
|
||
- sender_user_id Pflicht (Body oder Session)
|
||
- recipient_user_id Pflicht
|
||
- sender != recipient
|
||
- beide Konten muessen zur Praxis gehoeren
|
||
- Kein Fallback auf Allgemein. Kein audience=all.
|
||
"""
|
||
s = _session_from_request(request)
|
||
pid = (payload.practice_id or "").strip() or _resolve_practice_id(request)
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="practice_id erforderlich")
|
||
|
||
sender_uid = (payload.sender_user_id or "").strip()
|
||
if not sender_uid and s:
|
||
sender_uid = str(s.get("user_id") or "").strip()
|
||
recipient_uid = (payload.recipient_user_id or "").strip()
|
||
text = (payload.text or "").strip()
|
||
att_ids = [str(x or "").strip() for x in (payload.attachment_ids or []) if str(x or "").strip()]
|
||
legacy_att = list(payload.attachments or [])
|
||
|
||
if not sender_uid:
|
||
raise HTTPException(status_code=400, detail="sender_user_id erforderlich")
|
||
if not recipient_uid:
|
||
raise HTTPException(status_code=400, detail="recipient_user_id erforderlich")
|
||
if sender_uid == recipient_uid:
|
||
raise HTTPException(status_code=400, detail="Selbstchat nicht erlaubt")
|
||
|
||
acc_map = _accounts_by_practice(pid)
|
||
if sender_uid not in acc_map:
|
||
raise HTTPException(status_code=400, detail="sender_user_id gehoert nicht zu dieser Praxis")
|
||
if recipient_uid not in acc_map:
|
||
raise HTTPException(status_code=400, detail="recipient_user_id gehoert nicht zu dieser Praxis")
|
||
|
||
if len(att_ids) + len(legacy_att) > _ATTACHMENT_MAX_PER_MESSAGE:
|
||
raise HTTPException(status_code=400, detail="Zu viele Anhaenge")
|
||
|
||
conv_key = _direct_conv_key(pid, sender_uid, recipient_uid)
|
||
finalized_stored = _finalize_attachment_ids_internal(
|
||
att_ids, pid, sender_uid, recipient_uid, conv_key,
|
||
)
|
||
merged_attachments = legacy_att + finalized_stored
|
||
has_attachments = bool(merged_attachments)
|
||
|
||
if not text and not has_attachments:
|
||
raise HTTPException(status_code=400, detail="Leere Nachricht ohne Anhang nicht erlaubt")
|
||
|
||
sender_dn = (acc_map[sender_uid].get("display_name") or "").strip()
|
||
recipient_dn = (acc_map[recipient_uid].get("display_name") or "").strip()
|
||
|
||
msg_id = uuid.uuid4().hex[:12]
|
||
now = _utc_now_iso_z()
|
||
extras = {
|
||
"audience": "direct",
|
||
"rcpt_broadcast": False,
|
||
"recipient": recipient_dn,
|
||
"recipient_user_id": recipient_uid,
|
||
"sender_user_id": sender_uid,
|
||
"direct_conv_key": conv_key,
|
||
"dm_v2": True,
|
||
}
|
||
if payload.client_msg_id:
|
||
extras["client_msg_id"] = str(payload.client_msg_id)[:64]
|
||
if has_attachments:
|
||
extras["attachments"] = merged_attachments
|
||
|
||
entry = {
|
||
"id": msg_id,
|
||
"thread_id": msg_id,
|
||
"practice_id": pid,
|
||
"medikamente": "",
|
||
"therapieplan": "",
|
||
"procedere": "",
|
||
"kommentar": text or ("\u200b" if has_attachments else ""),
|
||
"patient": "Direkt: " + recipient_dn,
|
||
"absender": sender_dn + " (Empfang)",
|
||
"zeitstempel": now,
|
||
"empfangen": now,
|
||
"status": "offen",
|
||
"user_id": sender_uid,
|
||
"extras": extras,
|
||
}
|
||
|
||
messages = _load_messages()
|
||
messages.insert(0, entry)
|
||
_save_messages(messages)
|
||
|
||
try:
|
||
_pulse_bump(pid, sender=sender_dn)
|
||
except Exception:
|
||
pass
|
||
|
||
_log.info(
|
||
"AZA_CHAT_SEND mode=direct practice=%s sender=%s recipient=%s conv=%s msg=%s",
|
||
pid, sender_uid, recipient_uid, conv_key, msg_id,
|
||
)
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"ok": True,
|
||
"mode": "direct",
|
||
"message_id": msg_id,
|
||
"thread_id": msg_id,
|
||
"practice_id": pid,
|
||
"sender_user_id": sender_uid,
|
||
"recipient_user_id": recipient_uid,
|
||
"conversation_key": conv_key,
|
||
"created_at": now,
|
||
},
|
||
headers={
|
||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||
"Pragma": "no-cache",
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/dm/conversation")
|
||
async def empfang_dm_conversation(
|
||
request: Request,
|
||
sender_user_id: str = Query(""),
|
||
recipient_user_id: str = Query(""),
|
||
practice_id: Optional[str] = Query(None),
|
||
):
|
||
"""Liefert ausschliesslich Direct-Nachrichten dieses 1:1-Paares (per direct_conv_key).
|
||
Keine Heuristik, kein Broadcast-Fallback.
|
||
"""
|
||
s = _session_from_request(request)
|
||
pid = (practice_id or "").strip() or _resolve_practice_id(request)
|
||
if not pid:
|
||
raise HTTPException(status_code=400, detail="practice_id erforderlich")
|
||
|
||
me_uid = (sender_user_id or "").strip()
|
||
if not me_uid and s:
|
||
me_uid = str(s.get("user_id") or "").strip()
|
||
peer_uid = (recipient_user_id or "").strip()
|
||
|
||
if not me_uid:
|
||
raise HTTPException(status_code=400, detail="sender_user_id erforderlich")
|
||
if not peer_uid:
|
||
raise HTTPException(status_code=400, detail="recipient_user_id erforderlich")
|
||
if me_uid == peer_uid:
|
||
raise HTTPException(status_code=400, detail="Selbstchat nicht erlaubt")
|
||
|
||
acc_map = _accounts_by_practice(pid)
|
||
if me_uid not in acc_map:
|
||
raise HTTPException(status_code=400, detail="sender_user_id gehoert nicht zu dieser Praxis")
|
||
if peer_uid not in acc_map:
|
||
raise HTTPException(status_code=400, detail="recipient_user_id gehoert nicht zu dieser Praxis")
|
||
|
||
msgs, conv_key = _dm_v2_load_for_pair(pid, me_uid, peer_uid)
|
||
pulse = _pulse_get(pid)
|
||
|
||
_log.info(
|
||
"AZA_CHAT_LOAD mode=direct practice=%s me=%s peer=%s conv=%s count=%s",
|
||
pid, me_uid, peer_uid, conv_key, len(msgs),
|
||
)
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"ok": True,
|
||
"mode": "direct",
|
||
"practice_id": pid,
|
||
"sender_user_id": me_uid,
|
||
"recipient_user_id": peer_uid,
|
||
"conversation_key": conv_key,
|
||
"messages": msgs,
|
||
"count": len(msgs),
|
||
"tick": int(pulse.get("tick", 0)),
|
||
"ts": pulse.get("ts", 0.0),
|
||
},
|
||
headers={
|
||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||
"Pragma": "no-cache",
|
||
},
|
||
)
|
||
|
||
|
||
@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=_msg_chrono_sort_key)
|
||
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)
|
||
try:
|
||
_pulse_bump(pid, sender="")
|
||
except Exception:
|
||
pass
|
||
return JSONResponse(content={"success": True})
|
||
|
||
|
||
EMPFANG_MSG_TRANSCRIBE_MAX_BYTES = 2 * 1024 * 1024
|
||
|
||
|
||
def _attachment_is_audio_dict(a: object) -> bool:
|
||
if not isinstance(a, dict):
|
||
return False
|
||
if str(a.get("kind") or "").strip().lower() == "audio":
|
||
return True
|
||
mt = str(a.get("mime") or "").strip().lower()
|
||
if mt.startswith("audio/"):
|
||
return True
|
||
n = str(a.get("name") or "").strip().lower()
|
||
return bool(
|
||
re.search(r"\.(webm|ogg|opus|wav|mp3|m4a)$", n, re.I)
|
||
)
|
||
|
||
|
||
def _audio_suffix_for_attachment(att: dict) -> str:
|
||
"""Endung fuer die Tempfile abgeleitet aus dem MIME-Typ.
|
||
|
||
Wichtig: ``audio/webm`` und ``audio/webm;codecs=opus`` muessen ``.webm`` werden,
|
||
weil die Bytes ein WebM-Container sind (Edge/Chrome MediaRecorder). Erst danach
|
||
werden echte OGG-/Opus-Faelle wie ``audio/ogg`` oder ``audio/opus`` als ``.ogg``
|
||
bzw. ``.opus`` behandelt. Falsche Container-/Endungs-Kombination ist ein
|
||
bekannter Ausloeser fuer OpenAI BadRequestError.
|
||
"""
|
||
mt = str(att.get("mime") or "").lower()
|
||
if "webm" in mt:
|
||
return ".webm"
|
||
if "wav" in mt:
|
||
return ".wav"
|
||
if "mp4" in mt or "m4a" in mt or "aac" in mt:
|
||
return ".m4a"
|
||
if "ogg" in mt:
|
||
return ".ogg"
|
||
if "opus" in mt:
|
||
return ".opus"
|
||
if "mpeg" in mt or "mp3" in mt:
|
||
return ".mp3"
|
||
n = str(att.get("name") or "").lower()
|
||
if n.endswith(".wav"):
|
||
return ".wav"
|
||
if n.endswith(".m4a"):
|
||
return ".m4a"
|
||
if n.endswith(".ogg"):
|
||
return ".ogg"
|
||
if n.endswith(".opus"):
|
||
return ".opus"
|
||
if n.endswith(".mp3"):
|
||
return ".mp3"
|
||
return ".webm"
|
||
|
||
|
||
def _empfang_transcribe_openai_from_bytes(
|
||
audio_bytes: bytes,
|
||
*,
|
||
filename_suffix: str = ".webm",
|
||
) -> str:
|
||
"""Eine Audiodatei transkribieren (OpenAI wie backend /v1/transcribe). Tempfile wird geloescht."""
|
||
import backend_main as bm
|
||
|
||
tmp_path: Optional[str] = None
|
||
try:
|
||
client = bm._get_openai()
|
||
with tempfile.NamedTemporaryFile(
|
||
prefix="aza_ef_tr_",
|
||
suffix=filename_suffix,
|
||
delete=False,
|
||
) as tmp:
|
||
tmp.write(audio_bytes)
|
||
tmp_path = tmp.name
|
||
with open(tmp_path, "rb") as f:
|
||
is_gpt = "gpt-" in bm.TRANSCRIBE_MODEL
|
||
params: dict = dict(model=bm.TRANSCRIBE_MODEL, file=f, language="de")
|
||
dom = "medical"
|
||
chosen = (
|
||
bm.WHISPER_GENERAL_PROMPT
|
||
if dom == "general"
|
||
else bm.WHISPER_MEDICAL_PROMPT
|
||
)
|
||
if is_gpt:
|
||
params["prompt"] = bm.GPT_TRANSCRIBE_SHORT_PROMPT
|
||
else:
|
||
params["prompt"] = chosen
|
||
params["temperature"] = 0.0
|
||
resp = client.audio.transcriptions.create(**params)
|
||
text = getattr(resp, "text", "") or ""
|
||
if not text:
|
||
try:
|
||
if hasattr(resp, "model_dump"):
|
||
dd = resp.model_dump()
|
||
if isinstance(dd, dict):
|
||
text = dd.get("text", "") or ""
|
||
except Exception:
|
||
pass
|
||
t_stripped = text.lstrip()
|
||
if t_stripped.startswith(bm.WHISPER_PROMPT_PREFIX):
|
||
text = t_stripped[len(bm.WHISPER_PROMPT_PREFIX) :].lstrip(" :\t\r\n-")
|
||
text = (text or "").replace("ß", "ss")
|
||
text = bm.apply_medical_corrections(text, "")
|
||
text = bm.apply_medical_post_corrections(text)
|
||
text = bm.apply_medication_fuzzy_corrections(text)
|
||
return (text or "").strip()
|
||
finally:
|
||
if tmp_path:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
@router.post("/messages/{msg_id}/transcribe-audio")
|
||
async def empfang_message_transcribe_audio(msg_id: str, request: Request):
|
||
"""Transkribiert das erste Audio-Attachment einer Nachricht; speichert ``transcript`` am Attachment."""
|
||
_require_session(request)
|
||
pid = _require_practice_id(request)
|
||
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")
|
||
if _msg_practice(target) != pid:
|
||
raise HTTPException(status_code=403, detail="Kein Zugriff")
|
||
ex = dict(target.get("extras") or {})
|
||
attachments = ex.get("attachments")
|
||
if not isinstance(attachments, list):
|
||
attachments = []
|
||
idx: Optional[int] = None
|
||
att: Optional[dict] = None
|
||
for i, raw_a in enumerate(attachments):
|
||
if isinstance(raw_a, dict) and _attachment_is_audio_dict(raw_a):
|
||
idx = i
|
||
att = raw_a
|
||
break
|
||
if idx is None or not att:
|
||
raise HTTPException(status_code=400, detail="Keine Audiodatei in dieser Nachricht")
|
||
existing = str(att.get("transcript") or "").strip()
|
||
if existing:
|
||
mid_short = (msg_id or "")[:12]
|
||
_log.info(
|
||
"EMPFANG_TRANSCRIBE_CACHE msg=%s att=%s",
|
||
mid_short,
|
||
idx,
|
||
)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"transcript": existing,
|
||
"cached": True,
|
||
"attachment_index": idx,
|
||
}
|
||
)
|
||
b64 = str(att.get("data") or "").strip()
|
||
if not b64:
|
||
raise HTTPException(status_code=400, detail="Audiodaten fehlen")
|
||
try:
|
||
raw = base64.b64decode(b64, validate=False)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Audiodaten ungueltig")
|
||
nbytes = len(raw)
|
||
if nbytes < 1 or nbytes > EMPFANG_MSG_TRANSCRIBE_MAX_BYTES:
|
||
raise HTTPException(status_code=413, detail="Audiodatei zu gross")
|
||
suffix = _audio_suffix_for_attachment(att)
|
||
try:
|
||
transcript = _empfang_transcribe_openai_from_bytes(raw, filename_suffix=suffix)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
mid_short = (msg_id or "")[:12]
|
||
err_type = type(exc).__name__
|
||
try:
|
||
err_short = str(exc)
|
||
except Exception:
|
||
err_short = ""
|
||
err_short = (err_short or "").strip()
|
||
if len(err_short) > 200:
|
||
err_short = err_short[:200]
|
||
if err_short:
|
||
err_short = err_short.replace("\n", " ").replace("\r", " ")
|
||
_log.warning(
|
||
"EMPFANG_TRANSCRIBE_FAIL msg=%s bytes=%s suffix=%s err=%s short=%s",
|
||
mid_short,
|
||
nbytes,
|
||
suffix,
|
||
err_type,
|
||
err_short,
|
||
)
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=f"OpenAI: {err_type}",
|
||
) from exc
|
||
if not transcript:
|
||
mid_short = (msg_id or "")[:12]
|
||
_log.info("EMPFANG_TRANSCRIBE_EMPTY msg=%s bytes=%s", mid_short, nbytes)
|
||
raise HTTPException(status_code=502, detail="Leeres Transkript")
|
||
att_new = dict(att)
|
||
att_new["transcript"] = transcript
|
||
att_new["transcript_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
attachments = list(attachments)
|
||
attachments[idx] = att_new
|
||
ex["attachments"] = attachments
|
||
target["extras"] = ex
|
||
_save_messages(messages)
|
||
try:
|
||
_pulse_bump(pid, sender="")
|
||
except Exception:
|
||
pass
|
||
mid_short = (msg_id or "")[:12]
|
||
_log.info(
|
||
"EMPFANG_TRANSCRIBE_OK msg=%s bytes=%s",
|
||
mid_short,
|
||
nbytes,
|
||
)
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"transcript": transcript,
|
||
"cached": False,
|
||
"attachment_index": idx,
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/messages/{msg_id}/chat-ack")
|
||
async def empfang_message_chat_ack(msg_id: str, request: Request):
|
||
"""OK/Kenntnisnahme pro Nachricht (``extras.chat_ack``).
|
||
|
||
Optionale Bulk-Variante (rueckwaerts-kompatibel): Body
|
||
``{"ack": true, "scope": "thread_until_message"}`` quittiert zusaetzlich
|
||
*alle* aelteren eingehenden Nachrichten desselben ``direct_conv_key`` an
|
||
den aktuellen Empfaenger (Session-User), die noch ohne ``chat_ack`` sind
|
||
und chronologisch <= der geklickten Nachricht liegen.
|
||
|
||
- Eigene Nachrichten werden nie quittiert.
|
||
- Nichts ausserhalb der eigenen Praxis.
|
||
- Nichts in anderen Conversations / anderen Absendern.
|
||
- Bei ``ack=false`` (Toggle aus) wirkt nur die geklickte Nachricht.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = _require_practice_id(request)
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
ack = bool(body.get("ack"))
|
||
scope = str((body.get("scope") or "")).strip().lower()
|
||
me_uid = str((s.get("user_id") if s else "") or "").strip()
|
||
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")
|
||
if _msg_practice(target) != pid:
|
||
raise HTTPException(status_code=403, detail="Kein Zugriff")
|
||
|
||
bulk_targets: list[dict] = []
|
||
if ack and scope == "thread_until_message":
|
||
ex_clk = target.get("extras") or {}
|
||
clk_conv = str(ex_clk.get("direct_conv_key") or "").strip()
|
||
clk_recipient = str(ex_clk.get("recipient_user_id") or "").strip()
|
||
clk_sender = str(ex_clk.get("sender_user_id") or "").strip()
|
||
clk_chrono = _msg_chrono_sort_key(target)
|
||
# Bulk-Ack nur, wenn die geklickte Nachricht eine DM an den Session-User ist.
|
||
# Schutz gegen versehentliches Bulk auf eigene gesendete Nachrichten.
|
||
if clk_conv and me_uid and clk_recipient == me_uid and clk_sender and clk_sender != me_uid:
|
||
for m in messages:
|
||
if _msg_practice(m) != pid:
|
||
continue
|
||
ex_m = m.get("extras") or {}
|
||
if str(ex_m.get("direct_conv_key") or "").strip() != clk_conv:
|
||
continue
|
||
if str(ex_m.get("recipient_user_id") or "").strip() != me_uid:
|
||
continue
|
||
su_m = str(ex_m.get("sender_user_id") or "").strip()
|
||
if not su_m or su_m == me_uid:
|
||
continue
|
||
if su_m != clk_sender:
|
||
continue
|
||
if bool(ex_m.get("chat_ack")):
|
||
continue
|
||
if _msg_chrono_sort_key(m) > clk_chrono:
|
||
continue
|
||
bulk_targets.append(m)
|
||
|
||
affected = 0
|
||
target_id = str(target.get("id") or "").strip()
|
||
if bulk_targets:
|
||
for m in bulk_targets:
|
||
ex_m = dict(m.get("extras") or {})
|
||
ex_m["chat_ack"] = True
|
||
# Visuelle Markierung (Pastellgruen + Haekchen) NUR fuer die
|
||
# tatsaechlich geklickte Nachricht. Aeltere Bulk-Eintraege werden
|
||
# zwar quittiert (Alarm/Badge stoppt), bekommen aber kein gruenes
|
||
# Highlight, damit nur die angeklickte Nachricht hervorgehoben ist.
|
||
if str(m.get("id") or "").strip() == target_id:
|
||
ex_m["chat_ack_visual"] = True
|
||
else:
|
||
ex_m.pop("chat_ack_visual", None)
|
||
m["extras"] = ex_m
|
||
affected += 1
|
||
# Klick-Ziel ist normalerweise schon enthalten (chrono <= clk_chrono),
|
||
# aber sicherheitshalber separat sicherstellen:
|
||
ex_t = dict(target.get("extras") or {})
|
||
if not ex_t.get("chat_ack"):
|
||
ex_t["chat_ack"] = True
|
||
ex_t["chat_ack_visual"] = True
|
||
target["extras"] = ex_t
|
||
affected += 1
|
||
elif not ex_t.get("chat_ack_visual"):
|
||
ex_t["chat_ack_visual"] = True
|
||
target["extras"] = ex_t
|
||
else:
|
||
ex = dict(target.get("extras") or {})
|
||
if ack:
|
||
if not ex.get("chat_ack"):
|
||
ex["chat_ack"] = True
|
||
affected = 1
|
||
ex["chat_ack_visual"] = True
|
||
else:
|
||
if ex.pop("chat_ack", None) is not None:
|
||
affected = 1
|
||
ex.pop("chat_ack_visual", None)
|
||
target["extras"] = ex
|
||
|
||
_save_messages(messages)
|
||
try:
|
||
_pulse_bump(pid, sender="")
|
||
except Exception:
|
||
pass
|
||
mid_short = (msg_id or "")[:16]
|
||
pid_short = (pid or "")[:16]
|
||
_log.info(
|
||
"AZA_CHAT_ACK practice=%s msg=%s ack=%s scope=%s affected=%s",
|
||
pid_short, mid_short, ack, scope or "single", affected,
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"ack": ack,
|
||
"scope": scope or "single",
|
||
"affected": affected,
|
||
})
|
||
|
||
|
||
# Erlaubte Emoji-Reaktionen (kurze Whitelist; Server speichert nur diese).
|
||
_ALLOWED_REACTION_EMOJIS = {
|
||
"\U0001F44D", # 👍 thumbs up
|
||
"\u2764\uFE0F", # ❤️ red heart (mit VS16)
|
||
"\u2764", # ❤ ohne VS16
|
||
"\U0001F602", # 😂 laughing
|
||
"\U0001F62E", # 😮 surprised
|
||
"\U0001F622", # 😢 crying
|
||
"\U0001F64F", # 🙏 folded hands
|
||
}
|
||
|
||
|
||
@router.post("/messages/{msg_id}/reaction")
|
||
async def empfang_message_reaction(msg_id: str, request: Request):
|
||
"""Persistente Emoji-Reaktion eines Benutzers zu einer Nachricht.
|
||
|
||
Body: ``{"emoji": "<emoji>"}`` setzt/ersetzt; ``{"emoji": ""}`` entfernt.
|
||
Speicherung in ``extras.user_reactions = {user_id: emoji}``.
|
||
Ein Benutzer hat pro Nachricht max. eine Reaktion. Reaktionen sind
|
||
sichtbar fuer alle Mitglieder derselben Praxis.
|
||
|
||
Hinweis: Dieser Endpunkt setzt KEIN ``chat_ack``/OK-Quittierung;
|
||
das macht weiterhin /messages/{id}/chat-ack. Client darf bei 👍
|
||
beide Endpunkte aufrufen, um sowohl die Reaktion als auch die
|
||
Quittierung zu setzen.
|
||
"""
|
||
s = _require_session(request)
|
||
pid = _require_practice_id(request)
|
||
uid = str((s.get("user_id") if s else "") or "").strip()
|
||
if not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
raw_emoji = str((body.get("emoji") if isinstance(body, dict) else "") or "")
|
||
|
||
# Whitelist-Pruefung: leer = entfernen; alles andere muss erlaubt sein.
|
||
emoji = raw_emoji.strip()
|
||
if emoji and emoji not in _ALLOWED_REACTION_EMOJIS:
|
||
# Variant-Selector-Toleranz: ❤️ vs ❤
|
||
if emoji.rstrip("\uFE0F") in {e.rstrip("\uFE0F") for e in _ALLOWED_REACTION_EMOJIS}:
|
||
emoji = "\u2764\uFE0F" if emoji.startswith("\u2764") else emoji
|
||
else:
|
||
raise HTTPException(status_code=400, detail="Emoji nicht unterstuetzt")
|
||
|
||
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")
|
||
if _msg_practice(target) != pid:
|
||
raise HTTPException(status_code=403, detail="Kein Zugriff")
|
||
|
||
ex = dict(target.get("extras") or {})
|
||
reactions = dict(ex.get("user_reactions") or {})
|
||
if emoji:
|
||
reactions[uid] = emoji
|
||
else:
|
||
reactions.pop(uid, None)
|
||
if reactions:
|
||
ex["user_reactions"] = reactions
|
||
else:
|
||
ex.pop("user_reactions", None)
|
||
target["extras"] = ex
|
||
_save_messages(messages)
|
||
|
||
try:
|
||
_pulse_bump(pid, sender="")
|
||
except Exception:
|
||
pass
|
||
|
||
_log.info(
|
||
"AZA_CHAT_REACTION practice=%s msg=%s set=%s",
|
||
(pid or "")[:16], (msg_id or "")[:16], bool(emoji),
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"emoji": emoji,
|
||
"user_reactions": reactions,
|
||
})
|
||
|
||
|
||
@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)
|
||
try:
|
||
_pulse_bump(pid, sender="")
|
||
except Exception:
|
||
pass
|
||
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)
|
||
s = _session_from_request(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")
|
||
title_opt = (body.get("title") or "").strip()
|
||
meta_opt = (body.get("source_meta") or "").strip()
|
||
peer_opt = (body.get("source_peer") or "").strip()
|
||
stid_opt = (body.get("source_thread_id") or "").strip()
|
||
raw_kind = str(body.get("item_kind") or body.get("kind") or "task").strip().lower()
|
||
item_kind = "letter" if raw_kind == "letter" else "task"
|
||
task = {
|
||
"task_id": uuid.uuid4().hex[:12],
|
||
"practice_id": pid,
|
||
"text": text,
|
||
"title": title_opt or "",
|
||
"source_meta": meta_opt or "",
|
||
"source_peer": peer_opt or "",
|
||
"source_thread_id": stid_opt or "",
|
||
"done": False,
|
||
"assignee": (body.get("assignee") or "").strip(),
|
||
"created_by": (s or {}).get("display_name", "") if s else "",
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"source_msg_id": (body.get("source_msg_id") or "").strip(),
|
||
"item_kind": item_kind,
|
||
}
|
||
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 "title" in body:
|
||
target["title"] = (body.get("title") or "").strip()
|
||
if "assignee" in body:
|
||
target["assignee"] = (body.get("assignee") or "").strip()
|
||
if "source_meta" in body:
|
||
target["source_meta"] = (body.get("source_meta") 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_ts = time.time() - max_days * 86400
|
||
messages = _load_messages()
|
||
before = len(messages)
|
||
kept = []
|
||
for m in messages:
|
||
if _msg_practice(m) != pid:
|
||
kept.append(m)
|
||
continue
|
||
raw_t = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||
ts = _parse_msg_instant_utc_ts(raw_t)
|
||
if ts <= 0 or ts >= cutoff_ts:
|
||
kept.append(m)
|
||
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", ""),
|
||
"practice_specialty": str(p.get("specialty") or "").strip(),
|
||
"practice_timezone": str(p.get("timezone") or "").strip(),
|
||
"user_count": len(users),
|
||
"message_count": len(messages),
|
||
"open_count": open_count,
|
||
}
|
||
try:
|
||
from stripe_routes import lookup_license_email_for_practice
|
||
|
||
lcm = (lookup_license_email_for_practice(pid) or "").strip()
|
||
if lcm:
|
||
result["license_customer_email"] = lcm
|
||
except Exception:
|
||
pass
|
||
role_l = str(s.get("role") or "").strip().lower() if s else ""
|
||
is_sess_admin = bool(s and _is_admin_session(s))
|
||
show_invite = bool(api_token) or role_l in ("admin", "empfang") or is_sess_admin
|
||
if show_invite:
|
||
result["invite_code"] = p.get("invite_code", "")
|
||
if api_token or is_sess_admin:
|
||
result["admin_email"] = p.get("admin_email", "")
|
||
if api_token:
|
||
alerts = list(p.get("pdevice_alerts") or [])
|
||
result["pending_new_device_count"] = len(alerts)
|
||
if alerts:
|
||
by_uid = {u["user_id"]: u["display_name"] for u in _practice_users(pid)}
|
||
tail = alerts[-15:]
|
||
result["pending_new_devices"] = [
|
||
{
|
||
"user_id": str(x.get("user_id") or ""),
|
||
"display_name": by_uid.get(str(x.get("user_id") or ""), ""),
|
||
"device_suffix": str(x.get("device_suffix") or ""),
|
||
"ip": str(x.get("ip") or ""),
|
||
}
|
||
for x in tail
|
||
]
|
||
return JSONResponse(content=result)
|
||
|
||
|
||
@router.post("/practice/update_public_name")
|
||
async def empfang_update_public_practice_name(request: Request):
|
||
"""Setzt den oeffentlichen Praxisnamen fuer Chat/Empfang (Desktop-API-Token oder Admin-Session)."""
|
||
pid = _require_practice_admin_or_api_token(request)
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
pname = " ".join(
|
||
((body.get("practice_name") or body.get("name") or "").strip()).split()
|
||
)
|
||
if not pname or len(pname) > 240:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="practice_name erforderlich (1–240 Zeichen)",
|
||
)
|
||
spec_in = (
|
||
body.get("specialty") or body.get("practice_specialty") or ""
|
||
)
|
||
spec = " ".join(str(spec_in or "").strip().split()) if spec_in else ""
|
||
if spec and len(spec) > 160:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="specialty max. 160 Zeichen",
|
||
)
|
||
_ensure_practice(pid)
|
||
practices = _load_practices()
|
||
entry = practices.get(pid) or {}
|
||
entry["practice_id"] = pid
|
||
entry["name"] = pname
|
||
if spec:
|
||
entry["specialty"] = spec
|
||
practices[pid] = entry
|
||
_save_practices(practices)
|
||
return JSONResponse(content={
|
||
"success": True, "practice_id": pid, "practice_name": pname,
|
||
"practice_specialty": str((practices.get(pid) or {}).get("specialty") or "").strip(),
|
||
})
|
||
|
||
|
||
@router.get("/practice/profile")
|
||
async def empfang_practice_profile_get(request: Request):
|
||
"""Aggregiert Praxis- und (optional) Benutzerprofil fuer Desktop/Session. Nur Metadaten."""
|
||
pid = _presence_debug_resolve_practice_auth(request)
|
||
_ensure_practice(pid)
|
||
practices = _load_practices()
|
||
p = dict(practices.get(pid) or {})
|
||
uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
|
||
s = _session_from_request(request)
|
||
if s and not uid:
|
||
uid = str(s.get("user_id") or "").strip()
|
||
user_pub = None
|
||
if uid:
|
||
accounts0 = _load_accounts()
|
||
acc0 = accounts0.get(uid)
|
||
if acc0 and (acc0.get("practice_id") or "").strip() == pid:
|
||
user_pub = _user_profile_public_shape(acc0)
|
||
else:
|
||
uid = ""
|
||
user_pub = None
|
||
license_customer_email = ""
|
||
try:
|
||
from stripe_routes import lookup_license_email_for_practice
|
||
|
||
license_customer_email = (
|
||
lookup_license_email_for_practice(pid) or ""
|
||
).strip()
|
||
except Exception:
|
||
pass
|
||
practice_pub = _practice_profile_public_shape(p, pid)
|
||
warns = _practice_profile_warnings_payload(
|
||
practice_pub, user_pub, license_customer_email,
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"practice_id": pid,
|
||
"practice": practice_pub,
|
||
"license_customer_email": license_customer_email,
|
||
"user": user_pub,
|
||
"warnings": warns,
|
||
"read_only": True,
|
||
})
|
||
|
||
|
||
@router.patch("/practice/profile")
|
||
async def empfang_practice_profile_patch(request: Request):
|
||
"""Whitelist-PATCH fuer Praxis- und eigenes Benutzerprofil (API-Token/Admin)."""
|
||
pid = _require_practice_admin_or_api_token(request)
|
||
_ensure_practice(pid)
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
practice_in = body.get("practice") if isinstance(body.get("practice"), dict) else {}
|
||
user_in = body.get("user") if isinstance(body.get("user"), dict) else {}
|
||
actor_uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
|
||
sess = _session_from_request(request)
|
||
if sess and not actor_uid:
|
||
actor_uid = str(sess.get("user_id") or "").strip()
|
||
|
||
patched_user_id = ""
|
||
|
||
if practice_in:
|
||
practices = _load_practices()
|
||
entry = dict(practices.get(pid) or {})
|
||
entry["practice_id"] = pid
|
||
if "name" in practice_in:
|
||
nm = " ".join(str(practice_in.get("name") or "").strip().split())
|
||
if nm and len(nm) <= 240:
|
||
entry["name"] = nm
|
||
if "specialty" in practice_in:
|
||
sp = " ".join(str(practice_in.get("specialty") or "").strip().split())
|
||
if sp and len(sp) > 160:
|
||
raise HTTPException(status_code=400, detail="specialty max. 160")
|
||
if sp:
|
||
entry["specialty"] = sp
|
||
for fld, maxlen in (
|
||
("phone", 120),
|
||
("address", 500),
|
||
("website", 500),
|
||
("contact_email", 240),
|
||
):
|
||
if fld in practice_in:
|
||
v = " ".join(str(practice_in.get(fld) or "").strip().split())
|
||
if len(v) > maxlen:
|
||
raise HTTPException(status_code=400, detail=f"{fld} zu lang")
|
||
entry[fld] = v
|
||
entry["profile_updated_at"] = datetime.now(timezone.utc).strftime(
|
||
"%Y-%m-%dT%H:%M:%SZ",
|
||
)
|
||
if actor_uid:
|
||
entry["profile_updated_by_user_id"] = actor_uid[:32]
|
||
practices[pid] = entry
|
||
_save_practices(practices)
|
||
|
||
if user_in:
|
||
uid_tgt = str(user_in.get("user_id") or "").strip() or actor_uid
|
||
if not uid_tgt:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="user.user_id oder X-AzA-Empfang-User-Id erforderlich",
|
||
)
|
||
api_raw = (request.headers.get("X-API-Token") or "").strip()
|
||
hdr_uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
|
||
if api_raw and uid_tgt != hdr_uid:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Desktop-API: nur das eigene Benutzerprofil (user_id=Header)",
|
||
)
|
||
if sess and not api_raw:
|
||
su = str(sess.get("user_id") or "").strip()
|
||
if su != uid_tgt and not _is_admin_session(sess):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Nur eigenes Profil oder Admin-Sitzung",
|
||
)
|
||
accounts = _load_accounts()
|
||
tgt = accounts.get(uid_tgt)
|
||
if not tgt or (tgt.get("practice_id") or "").strip() != pid:
|
||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
|
||
if "display_name" in user_in:
|
||
dn = " ".join(str(user_in.get("display_name") or "").strip().split())
|
||
if not dn:
|
||
raise HTTPException(status_code=400, detail="display_name erforderlich")
|
||
if len(dn) > 200:
|
||
raise HTTPException(status_code=400, detail="display_name zu lang")
|
||
tgt["display_name"] = dn
|
||
if "title" in user_in:
|
||
ti = " ".join(str(user_in.get("title") or "").strip().split())
|
||
if len(ti) > 80:
|
||
raise HTTPException(status_code=400, detail="title zu lang")
|
||
tgt["title"] = ti
|
||
sp_key = None
|
||
if "specialty_user" in user_in:
|
||
sp_key = user_in.get("specialty_user")
|
||
elif "specialty" in user_in:
|
||
sp_key = user_in.get("specialty")
|
||
if sp_key is not None:
|
||
sp = " ".join(str(sp_key or "").strip().split())
|
||
if len(sp) > 160:
|
||
raise HTTPException(status_code=400, detail="specialty max. 160")
|
||
tgt["specialty"] = sp
|
||
if "email" in user_in:
|
||
raw_em = user_in.get("email")
|
||
tgt["email"] = (raw_em or "").strip() if isinstance(raw_em, str) else ""
|
||
if "job_function" in user_in:
|
||
jf = " ".join(str(user_in.get("job_function") or "").strip().split())
|
||
if len(jf) > 160:
|
||
raise HTTPException(status_code=400, detail="job_function zu lang")
|
||
tgt["job_function"] = jf
|
||
tgt["profile_updated_at"] = datetime.now(timezone.utc).strftime(
|
||
"%Y-%m-%dT%H:%M:%SZ",
|
||
)
|
||
accounts[uid_tgt] = tgt
|
||
_save_accounts(accounts)
|
||
patched_user_id = uid_tgt
|
||
|
||
practices_out = _load_practices()
|
||
p_out = dict(practices_out.get(pid) or {})
|
||
practice_pub = _practice_profile_public_shape(p_out, pid)
|
||
license_customer_email = ""
|
||
try:
|
||
from stripe_routes import lookup_license_email_for_practice
|
||
|
||
license_customer_email = (
|
||
lookup_license_email_for_practice(pid) or ""
|
||
).strip()
|
||
except Exception:
|
||
pass
|
||
user_pub = None
|
||
show_uid = patched_user_id or actor_uid
|
||
if show_uid:
|
||
au = _load_accounts().get(show_uid)
|
||
if au and (au.get("practice_id") or "").strip() == pid:
|
||
user_pub = _user_profile_public_shape(au)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"practice_id": pid,
|
||
"practice": practice_pub,
|
||
"license_customer_email": license_customer_email,
|
||
"user": user_pub,
|
||
"warnings": _practice_profile_warnings_payload(
|
||
practice_pub, user_pub, license_customer_email,
|
||
),
|
||
})
|
||
|
||
|
||
@router.post("/practice/clear_device_alerts")
|
||
async def empfang_clear_device_alerts(request: Request):
|
||
"""Loescht neue-Geraet-Hinweise fuer die mandantenrichtige Praxis (Desktop/API-Token)."""
|
||
pid = _presence_debug_resolve_practice_auth(request)
|
||
practices = _load_practices()
|
||
pdata = practices.get(pid)
|
||
if isinstance(pdata, dict):
|
||
pdata["pdevice_alerts"] = []
|
||
practices[pid] = pdata
|
||
_save_practices(practices)
|
||
return JSONResponse(content={"success": True})
|
||
# Shell session (kurzlebiger Desktop-Web-Huelle-Bootstrap, API-validiert)
|
||
# =====================================================================
|
||
#
|
||
# Nur POST /shell/session darf mittels gueltigem MEDWORK-X-API-Token + Practice
|
||
# einen shell_token ausstellen (kein Browser-Cookie als Identitaetsgrundlage).
|
||
#
|
||
# POST /shell/consume tauscht bearer shell_token gegen normale HttpOnly-Session —
|
||
# ohne API-Token, damit spaetere Web-Huelle den Token ohne Desktop-Shared-Secret
|
||
# einloesen kann.
|
||
|
||
_SHELL_TTL_DEFAULT = 300
|
||
_SHELL_PURPOSE = "send_to_reception_shell"
|
||
_shell_store: dict[str, dict] = {}
|
||
|
||
# Kurzlebiger Desktop-Kontext fuer WebView (Therapie/Procedere, RAM, kein Persist).
|
||
_DESKTOP_SHELL_CONTEXT_TTL_SECONDS = 900.0
|
||
_desktop_shell_latest_context_by_user: dict[tuple[str, str], dict] = {}
|
||
|
||
|
||
|
||
def _shell_cleanup_expired() -> None:
|
||
now = time.time()
|
||
stale = [
|
||
tok for tok, rec in _shell_store.items()
|
||
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
|
||
]
|
||
for tok in stale:
|
||
try:
|
||
del _shell_store[tok]
|
||
except KeyError:
|
||
pass
|
||
|
||
|
||
def _desktop_shell_context_cleanup() -> None:
|
||
now = time.time()
|
||
stale = [
|
||
k for k, rec in _desktop_shell_latest_context_by_user.items()
|
||
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
|
||
]
|
||
for k in stale:
|
||
try:
|
||
del _desktop_shell_latest_context_by_user[k]
|
||
except KeyError:
|
||
pass
|
||
|
||
|
||
def _require_shell_api_identity(request: Request) -> Tuple[str, str]:
|
||
"""Validiert X-API-Token wie aza_security; liefert (practice_id, desktop_user_id)."""
|
||
api_raw = (request.headers.get("X-API-Token") or "").strip()
|
||
if not api_raw:
|
||
raise HTTPException(status_code=401, detail="API-Token erforderlich")
|
||
try:
|
||
from aza_security import get_required_api_tokens
|
||
allowed = get_required_api_tokens()
|
||
except RuntimeError:
|
||
raise HTTPException(status_code=503, detail="API token nicht konfiguriert")
|
||
if not any(hmac.compare_digest(api_raw, t) for t in allowed):
|
||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||
|
||
pid = request.headers.get("X-Practice-Id", "").strip()
|
||
if not pid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="X-Practice-Id erforderlich",
|
||
)
|
||
|
||
claimed_uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
|
||
if not claimed_uid:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=(
|
||
"X-AzA-Empfang-User-Id fehlt: Desktop muss vom Server ermittelte "
|
||
"user_id aus dem Provisionierungs-/Users-Endpunkt mitschicken."
|
||
),
|
||
)
|
||
|
||
accounts = _load_accounts()
|
||
acc = accounts.get(claimed_uid)
|
||
if not acc or (acc.get("practice_id") or "").strip() != pid:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Benutzer gehoert nicht zu dieser Praxis oder user_id ungueltig",
|
||
)
|
||
if (acc.get("status") or "active") != "active":
|
||
raise HTTPException(status_code=403, detail="Benutzerkonto nicht aktiv")
|
||
|
||
return pid, claimed_uid
|
||
|
||
|
||
def _session_or_shell_identity(request: Request) -> dict:
|
||
"""Cookie-Session (Browser) oder Desktop mit X-API-Token + X-Practice-Id + User-Id."""
|
||
s = _session_from_request(request)
|
||
if s:
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if pid and uid:
|
||
return s
|
||
api_raw = (request.headers.get("X-API-Token") or "").strip()
|
||
if api_raw:
|
||
pid, uid = _require_shell_api_identity(request)
|
||
return {"practice_id": pid, "user_id": uid}
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
|
||
|
||
def _consume_shell_token_core(request: Request, shell_raw: str) -> Tuple[str, dict]:
|
||
"""POP shell_token, erstelle Serversession. Liefert (aza_session-Wert, Kennzeichen ohne Geheimnis)."""
|
||
_shell_cleanup_expired()
|
||
stok = (shell_raw or "").strip()
|
||
if not stok:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="shell_token fehlt",
|
||
)
|
||
|
||
rec = _shell_store.get(stok)
|
||
if not rec:
|
||
raise HTTPException(
|
||
status_code=401,
|
||
detail="Shell-Token ungueltig, verbraucht oder abgelaufen",
|
||
)
|
||
|
||
if time.time() > float(rec.get("expires_at", 0)):
|
||
try:
|
||
del _shell_store[stok]
|
||
except KeyError:
|
||
pass
|
||
raise HTTPException(status_code=401, detail="Shell-Token abgelaufen")
|
||
|
||
try:
|
||
del _shell_store[stok]
|
||
except KeyError:
|
||
raise HTTPException(
|
||
status_code=401,
|
||
detail="Shell-Token ungueltig, verbraucht oder abgelaufen",
|
||
)
|
||
|
||
uid = (rec.get("user_id") or "").strip()
|
||
pid = (rec.get("practice_id") or "").strip()
|
||
dn = (rec.get("display_name") or "").strip()
|
||
role = (rec.get("role") or "mpa").strip()
|
||
|
||
ua = request.headers.get("User-Agent") or "AzA-Shell-Client"
|
||
ip = _extract_client_ip(request)
|
||
sess_token = _create_session(
|
||
uid,
|
||
pid,
|
||
dn,
|
||
role,
|
||
device_id="",
|
||
user_agent=ua,
|
||
ip_addr=ip,
|
||
)
|
||
|
||
_log.info(
|
||
"AZA_SHELL_SESSION_CONSUMED practice=%s user=%s purpose=%s",
|
||
pid,
|
||
uid,
|
||
_SHELL_PURPOSE,
|
||
)
|
||
public = {
|
||
"user_id": uid,
|
||
"practice_id": pid,
|
||
"display_name": dn,
|
||
"role": role,
|
||
"purpose": _SHELL_PURPOSE,
|
||
}
|
||
return sess_token, public
|
||
|
||
|
||
@router.post("/shell/session")
|
||
async def empfang_shell_session_create(request: Request):
|
||
"""Erzeugt kurzlebigen shell_token (nur mit Desktop-API-Token + validierter user_id)."""
|
||
_shell_cleanup_expired()
|
||
pid, uid = _require_shell_api_identity(request)
|
||
|
||
accounts = _load_accounts()
|
||
acc = accounts.get(uid) or {}
|
||
display_name = (acc.get("display_name") or "").strip() or "Benutzer"
|
||
role = (acc.get("role") or "mpa").strip()
|
||
|
||
now = time.time()
|
||
expires_at = now + float(_SHELL_TTL_DEFAULT)
|
||
shell_token = secrets.token_urlsafe(32)
|
||
|
||
_shell_store[shell_token] = {
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
"display_name": display_name,
|
||
"role": role,
|
||
"purpose": _SHELL_PURPOSE,
|
||
"expires_at": expires_at,
|
||
"created_at": now,
|
||
}
|
||
|
||
expires_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires_at))
|
||
|
||
_log.info(
|
||
"AZA_SHELL_SESSION_CREATED practice=%s user=%s purpose=%s ttl=%s",
|
||
pid,
|
||
uid,
|
||
_SHELL_PURPOSE,
|
||
int(_SHELL_TTL_DEFAULT),
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"shell_token": shell_token,
|
||
"expires_at": expires_iso,
|
||
"expires_at_unix": int(expires_at),
|
||
"ttl_seconds": int(_SHELL_TTL_DEFAULT),
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
"display_name": display_name,
|
||
"role": role,
|
||
"purpose": _SHELL_PURPOSE,
|
||
})
|
||
|
||
|
||
@router.post("/shell/context")
|
||
async def empfang_shell_context_upload(request: Request):
|
||
"""Desktop: Therapie/Procedere-Vorschau fuer WebView-Huelle (RAM, TTL, keine URL).
|
||
|
||
Keine medizinischen Inhalte loggen."""
|
||
_desktop_shell_context_cleanup()
|
||
pid, uid = _require_shell_api_identity(request)
|
||
|
||
body: dict
|
||
try:
|
||
body_raw = await request.json()
|
||
body = body_raw if isinstance(body_raw, dict) else {}
|
||
except Exception:
|
||
body = {}
|
||
|
||
therapy_raw = body.get("therapy_text")
|
||
proc_raw = body.get("procedure_text")
|
||
therapy_text = (therapy_raw if isinstance(therapy_raw, str) else str(therapy_raw or ""))[:120000]
|
||
procedure_text = (proc_raw if isinstance(proc_raw, str) else str(proc_raw or ""))[:120000]
|
||
therapy_autocopy = body.get("therapy_autocopy") in (True, "true", "1", 1)
|
||
procedure_autocopy = body.get("procedure_autocopy") in (True, "true", "1", 1)
|
||
|
||
dm_uid_raw = body.get("dm_open_peer_user_id")
|
||
dm_dn_raw = body.get("dm_open_display_name")
|
||
dm_open_peer_user_id = (
|
||
str(dm_uid_raw if dm_uid_raw is not None else "").strip()[:64]
|
||
)
|
||
dm_open_display_name = (
|
||
str(dm_dn_raw if dm_dn_raw is not None else "").strip()[:200]
|
||
)
|
||
|
||
now = time.time()
|
||
expires_at = now + float(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS)
|
||
ctx_id = "ctx_" + secrets.token_urlsafe(24)
|
||
|
||
record = {
|
||
"context_id": ctx_id,
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
"therapy_text": therapy_text,
|
||
"procedure_text": procedure_text,
|
||
"therapy_autocopy": therapy_autocopy,
|
||
"procedure_autocopy": procedure_autocopy,
|
||
"dm_open_peer_user_id": dm_open_peer_user_id,
|
||
"dm_open_display_name": dm_open_display_name,
|
||
"expires_at": expires_at,
|
||
"updated_at": now,
|
||
}
|
||
_desktop_shell_latest_context_by_user[(pid, uid)] = record
|
||
|
||
_log.info(
|
||
"DESKTOP_SHELL_CONTEXT_PUT practice=%s user=%s len_ctx_id=%s nonempty=%s/%s",
|
||
pid,
|
||
uid,
|
||
len(ctx_id),
|
||
int(bool(therapy_text.strip())),
|
||
int(bool(procedure_text.strip())),
|
||
)
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"context_id": ctx_id,
|
||
"ttl_seconds": int(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS),
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
|
||
|
||
@router.get("/shell/context/me")
|
||
async def empfang_shell_context_me(request: Request):
|
||
"""Shell-WebView: eigener Kontext fuer angemeldete Session (Practice/User-Match).
|
||
|
||
Nur Metadaten im Server-Log."""
|
||
_desktop_shell_context_cleanup()
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
|
||
rec = _desktop_shell_latest_context_by_user.get((pid, uid))
|
||
now = time.time()
|
||
if not rec or float(rec.get("expires_at", 0)) < now:
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"context_id": "",
|
||
"therapy_text": "",
|
||
"procedure_text": "",
|
||
"therapy_autocopy": False,
|
||
"procedure_autocopy": False,
|
||
"dm_open_peer_user_id": "",
|
||
"dm_open_display_name": "",
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
|
||
cid = str(rec.get("context_id") or "")
|
||
_log.info(
|
||
"DESKTOP_SHELL_CONTEXT_GET practice=%s user=%s len_ctx_id=%s",
|
||
pid,
|
||
uid,
|
||
len(cid),
|
||
)
|
||
|
||
dm_uid_out = str(rec.get("dm_open_peer_user_id") or "")
|
||
dm_dn_out = str(rec.get("dm_open_display_name") or "")
|
||
if dm_uid_out:
|
||
rec["dm_open_peer_user_id"] = ""
|
||
rec["dm_open_display_name"] = ""
|
||
_desktop_shell_latest_context_by_user[(pid, uid)] = rec
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"success": True,
|
||
"context_id": cid,
|
||
"therapy_text": str(rec.get("therapy_text") or ""),
|
||
"procedure_text": str(rec.get("procedure_text") or ""),
|
||
"therapy_autocopy": bool(rec.get("therapy_autocopy")),
|
||
"procedure_autocopy": bool(rec.get("procedure_autocopy")),
|
||
"dm_open_peer_user_id": dm_uid_out,
|
||
"dm_open_display_name": dm_dn_out,
|
||
},
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||
)
|
||
|
||
|
||
@router.post("/shell/consume")
|
||
async def empfang_shell_consume(request: Request):
|
||
"""Tauscht shell_token gegen aza_session (HttpOnly Cookie). Einmal-Verbrauch."""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
shell_raw = ""
|
||
if isinstance(body, dict):
|
||
shell_raw = (body.get("shell_token") or "").strip()
|
||
|
||
if not shell_raw:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="shell_token im JSON Body erforderlich",
|
||
)
|
||
|
||
sess_token, pub = _consume_shell_token_core(request, shell_raw)
|
||
resp = JSONResponse(content={"success": True, **pub})
|
||
resp.set_cookie(
|
||
"aza_session",
|
||
sess_token,
|
||
httponly=True,
|
||
samesite="lax",
|
||
max_age=SESSION_MAX_AGE,
|
||
)
|
||
return resp
|
||
|
||
|
||
@router.get("/shell/launch")
|
||
async def empfang_shell_launch(
|
||
request: Request,
|
||
token: str = Query("", description="kurzlebiger Shell-Token (einmaliger Verbrauch)"),
|
||
target: str = Query("", description="optional: 'empfang_chat_shell' fuer Empfang-Huelle"),
|
||
):
|
||
"""Web-Huelle: Token per GET einloesen — setzt Cookie, Redirect ohne Token in URL.
|
||
|
||
Einmal-Verbrauch. Kein MEDWORK_API-Token hier (Browser/WebView ohne Desktop-Secret).
|
||
target=empfang_chat_shell markiert die separate Empfang-Chat-Huelle (kein Arzt-Desktop).
|
||
"""
|
||
sess_token, _pub = _consume_shell_token_core(request, token)
|
||
|
||
t = (target or "").strip().lower()
|
||
if t == "empfang_chat_shell":
|
||
loc = "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell"
|
||
else:
|
||
loc = "/empfang/?desktop_shell=1&shell_source=aza_desktop"
|
||
resp = RedirectResponse(url=loc, status_code=302)
|
||
resp.set_cookie(
|
||
"aza_session",
|
||
sess_token,
|
||
httponly=True,
|
||
samesite="lax",
|
||
max_age=SESSION_MAX_AGE,
|
||
)
|
||
return resp
|
||
|
||
|
||
# =====================================================================
|
||
# Empfang-Chat-Huelle: Browser->native-Huelle Handoff
|
||
# =====================================================================
|
||
#
|
||
# Ziel: Eingeloggter Browser-Empfang kann die separat installierte
|
||
# native Empfang-Chat-Huelle (AZA_EmpfangShell.exe) mit derselben Praxis-/
|
||
# Chat-Session verbinden, OHNE Arztlizenz / X-API-Token.
|
||
#
|
||
# Sicherheitsmodell:
|
||
# - /empfang/handoff/create erfordert eingeloggte Empfang-Session (Cookie).
|
||
# - Erzeugt einen kurzlebigen shell_token (gleiches _shell_store wie Desktop-
|
||
# Launch) plus einen kurzen, lesbaren Verbindungscode XXXX-XXXX, der nur
|
||
# auf den shell_token verweist (eigener Store, einmalig konsumiert).
|
||
# - /empfang/handoff/lookup loest den Verbindungscode in den shell_token auf.
|
||
# Kein Login noetig - der Code IST das Geheimnis. Einmal-Verbrauch.
|
||
# - Der shell_token selbst wird wie gewohnt durch /empfang/shell/launch
|
||
# einmal verbraucht (HttpOnly Cookie in der nativen Huelle gesetzt).
|
||
# - Keine Chatdaten werden im Handoff transportiert.
|
||
|
||
_HANDOFF_TTL_DEFAULT = 300 # 5 Minuten
|
||
_HANDOFF_PURPOSE = "empfang_chat_shell_handoff"
|
||
_handoff_short_codes: dict[str, dict] = {}
|
||
|
||
|
||
def _handoff_short_codes_cleanup() -> None:
|
||
now = time.time()
|
||
stale = [
|
||
c for c, rec in _handoff_short_codes.items()
|
||
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
|
||
]
|
||
for c in stale:
|
||
try:
|
||
del _handoff_short_codes[c]
|
||
except KeyError:
|
||
pass
|
||
|
||
|
||
def _normalize_short_code(raw: str) -> str:
|
||
"""Vergleichbar machen: Grossbuchstaben, Bindestrich-Varianten -> '-', Leerzeichen weg."""
|
||
s = (raw or "").strip().upper().replace(" ", "")
|
||
for ch in ("\u2011", "\u2013", "\u2014", "\u2212", "_"):
|
||
s = s.replace(ch, "-")
|
||
return s
|
||
|
||
|
||
def _generate_short_handoff_code() -> str:
|
||
"""8 Zeichen lesbar, ohne 0/O/1/I-Verwechslungen, im Format XXXX-XXXX."""
|
||
import random
|
||
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||
a = "".join(random.choices(chars, k=4))
|
||
b = "".join(random.choices(chars, k=4))
|
||
return f"{a}-{b}"
|
||
|
||
|
||
@router.post("/handoff/create")
|
||
async def empfang_handoff_create(request: Request):
|
||
"""Browser-Empfang -> Empfang-Chat-Huelle: kurzlebiger Handoff-Token + Verbindungscode.
|
||
|
||
Erfordert eine bestehende Empfang-Session (HttpOnly-Cookie).
|
||
Keine Arzt-Lizenz / kein X-API-Token. Kein Chat-Inhalt im Token.
|
||
"""
|
||
_shell_cleanup_expired()
|
||
_handoff_short_codes_cleanup()
|
||
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
accounts = _load_accounts()
|
||
acc = accounts.get(uid) or {}
|
||
display_name = (acc.get("display_name")
|
||
or s.get("display_name") or "").strip() or "Benutzer"
|
||
role = (acc.get("role") or s.get("role") or "mpa").strip()
|
||
if (acc.get("status") or "active") != "active":
|
||
raise HTTPException(status_code=403, detail="Benutzerkonto nicht aktiv")
|
||
|
||
now = time.time()
|
||
expires_at = now + float(_HANDOFF_TTL_DEFAULT)
|
||
shell_token = secrets.token_urlsafe(32)
|
||
|
||
# Eindeutigen kurzen Verbindungscode erzeugen
|
||
short_code = _generate_short_handoff_code()
|
||
tries = 0
|
||
while short_code in _handoff_short_codes and tries < 8:
|
||
short_code = _generate_short_handoff_code()
|
||
tries += 1
|
||
|
||
_shell_store[shell_token] = {
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
"display_name": display_name,
|
||
"role": role,
|
||
"purpose": _HANDOFF_PURPOSE,
|
||
"expires_at": expires_at,
|
||
"created_at": now,
|
||
}
|
||
_handoff_short_codes[short_code] = {
|
||
"shell_token": shell_token,
|
||
"expires_at": expires_at,
|
||
"practice_id": pid,
|
||
"user_id": uid,
|
||
}
|
||
|
||
expires_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires_at))
|
||
launch_path = (
|
||
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
|
||
)
|
||
|
||
_log.info(
|
||
"AZA_EMPFANG_HANDOFF_CREATED practice=%s user=%s purpose=%s ttl=%s",
|
||
pid, uid, _HANDOFF_PURPOSE, int(_HANDOFF_TTL_DEFAULT),
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"short_code": short_code,
|
||
"launch_path": launch_path,
|
||
"expires_at": expires_iso,
|
||
"expires_at_unix": int(expires_at),
|
||
"ttl_seconds": int(_HANDOFF_TTL_DEFAULT),
|
||
"purpose": _HANDOFF_PURPOSE,
|
||
})
|
||
|
||
|
||
@router.get("/handoff/lookup")
|
||
async def empfang_handoff_lookup(
|
||
request: Request,
|
||
code: str = Query("", description="kurzer Verbindungscode XXXX-XXXX"),
|
||
):
|
||
"""Empfang-Chat-Huelle: Verbindungscode in shell_token aufloesen.
|
||
|
||
Kein Cookie/Session noetig — der Code IST das Geheimnis. Einmal-Verbrauch
|
||
der Code->Token-Zuordnung. Der shell_token wird erst spaeter durch
|
||
/empfang/shell/launch eingeloest.
|
||
"""
|
||
_shell_cleanup_expired()
|
||
_handoff_short_codes_cleanup()
|
||
|
||
c = _normalize_short_code(code)
|
||
if not c:
|
||
raise HTTPException(status_code=400, detail="code fehlt")
|
||
|
||
rec = _handoff_short_codes.get(c)
|
||
if not rec:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Verbindungscode ungueltig oder bereits eingeloest",
|
||
)
|
||
if time.time() > float(rec.get("expires_at", 0)):
|
||
try:
|
||
del _handoff_short_codes[c]
|
||
except KeyError:
|
||
pass
|
||
raise HTTPException(status_code=410, detail="Verbindungscode abgelaufen")
|
||
|
||
shell_token = (rec.get("shell_token") or "").strip()
|
||
try:
|
||
del _handoff_short_codes[c]
|
||
except KeyError:
|
||
pass
|
||
|
||
if not shell_token or shell_token not in _shell_store:
|
||
raise HTTPException(
|
||
status_code=410,
|
||
detail="Verbindungscode war gueltig, aber zugehoerige Session abgelaufen",
|
||
)
|
||
|
||
launch_path = (
|
||
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
|
||
)
|
||
_log.info("AZA_EMPFANG_HANDOFF_REDEEMED purpose=%s", _HANDOFF_PURPOSE)
|
||
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"launch_path": launch_path,
|
||
"purpose": _HANDOFF_PURPOSE,
|
||
})
|
||
|
||
|
||
# =====================================================================
|
||
# Textbloecke (pro practice_id + user_id, serverseitig)
|
||
# =====================================================================
|
||
#
|
||
# Persistente Textbloecke werden in einer JSON-Datei pro Praxis und
|
||
# Benutzer gespeichert: {practice_id: {user_id: [ {id,title,body,
|
||
# sort_order,created_at,updated_at}, ... ]}}.
|
||
#
|
||
# Sicherheitsmodell:
|
||
# - Alle Routen erfordern eine bestehende Empfang-Session (Cookie).
|
||
# - Ein Benutzer sieht ausschliesslich seine eigenen Textbloecke.
|
||
# - Inhalte werden im aza_audit_log NICHT geloggt (keine Patientendaten).
|
||
|
||
_TEXTBLOCKS_FILE = _DATA_DIR / "empfang_textblocks.json"
|
||
_TEXTBLOCK_MAX_TITLE = 200
|
||
_TEXTBLOCK_MAX_BODY = 8000
|
||
_TEXTBLOCK_MAX_PER_USER = 200
|
||
|
||
|
||
def _load_textblocks_store() -> dict:
|
||
"""{practice_id: {user_id: [textblock dict, ...]}}."""
|
||
return _load_json(_TEXTBLOCKS_FILE, {})
|
||
|
||
|
||
def _save_textblocks_store(data: dict) -> None:
|
||
_save_json(_TEXTBLOCKS_FILE, data)
|
||
|
||
|
||
def _user_textblocks(store: dict, pid: str, uid: str) -> list:
|
||
pdat = store.get(pid)
|
||
if not isinstance(pdat, dict):
|
||
return []
|
||
udat = pdat.get(uid)
|
||
if not isinstance(udat, list):
|
||
return []
|
||
return [tb for tb in udat if isinstance(tb, dict) and tb.get("id")]
|
||
|
||
|
||
def _generate_textblock_id() -> str:
|
||
return f"tb_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
||
|
||
|
||
def _clip_text(s: str, max_len: int) -> str:
|
||
s = "" if s is None else str(s)
|
||
if len(s) > max_len:
|
||
return s[:max_len]
|
||
return s
|
||
|
||
|
||
def _public_textblock(tb: dict) -> dict:
|
||
"""Felder, die der Client erhaelt."""
|
||
return {
|
||
"id": str(tb.get("id") or ""),
|
||
"title": str(tb.get("title") or ""),
|
||
"body": str(tb.get("body") or ""),
|
||
"sort_order": int(tb.get("sort_order") or 0),
|
||
"created_at": str(tb.get("created_at") or ""),
|
||
"updated_at": str(tb.get("updated_at") or ""),
|
||
}
|
||
|
||
|
||
@router.get("/textblocks")
|
||
async def empfang_textblocks_list(request: Request):
|
||
"""Liste der Textbloecke des angemeldeten Benutzers in der aktuellen Praxis."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
store = _load_textblocks_store()
|
||
blocks = _user_textblocks(store, pid, uid)
|
||
blocks_sorted = sorted(
|
||
blocks,
|
||
key=lambda x: (int(x.get("sort_order") or 0), str(x.get("created_at") or "")),
|
||
)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"textblocks": [_public_textblock(tb) for tb in blocks_sorted],
|
||
})
|
||
|
||
|
||
@router.post("/textblocks")
|
||
async def empfang_textblocks_create(request: Request):
|
||
"""Erzeugt einen neuen Textblock. Body: {title, body, sort_order?, client_id?}."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
if not isinstance(body, dict):
|
||
body = {}
|
||
|
||
title = _clip_text(body.get("title", ""), _TEXTBLOCK_MAX_TITLE).strip()
|
||
content = _clip_text(body.get("body", ""), _TEXTBLOCK_MAX_BODY)
|
||
if not content.strip():
|
||
raise HTTPException(status_code=400, detail="body erforderlich")
|
||
if not title:
|
||
title = "Textblock"
|
||
|
||
sort_order = 0
|
||
try:
|
||
sort_order = int(body.get("sort_order") or 0)
|
||
except Exception:
|
||
sort_order = 0
|
||
|
||
# Idempotenz-Hint: ``client_id`` wird wenn vorhanden als id uebernommen,
|
||
# solange noch nicht vergeben.
|
||
client_id = str(body.get("client_id") or "").strip()
|
||
if client_id and not client_id.startswith("tb_"):
|
||
client_id = ""
|
||
|
||
store = _load_textblocks_store()
|
||
pdat = store.setdefault(pid, {})
|
||
udat = pdat.setdefault(uid, [])
|
||
|
||
if len(udat) >= _TEXTBLOCK_MAX_PER_USER:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Maximal {_TEXTBLOCK_MAX_PER_USER} Textbloecke pro Benutzer",
|
||
)
|
||
|
||
tb_id = client_id if client_id and not any(
|
||
isinstance(x, dict) and x.get("id") == client_id for x in udat
|
||
) else _generate_textblock_id()
|
||
|
||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
tb = {
|
||
"id": tb_id,
|
||
"title": title,
|
||
"body": content,
|
||
"sort_order": sort_order,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
udat.append(tb)
|
||
_save_textblocks_store(store)
|
||
return JSONResponse(content={"success": True, "textblock": _public_textblock(tb)})
|
||
|
||
|
||
@router.put("/textblocks/{tb_id}")
|
||
async def empfang_textblocks_update(tb_id: str, request: Request):
|
||
"""Aktualisiert title/body/sort_order eines Textblocks. Nur eigene."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
if not isinstance(body, dict):
|
||
body = {}
|
||
|
||
store = _load_textblocks_store()
|
||
udat = store.get(pid, {}).get(uid)
|
||
if not isinstance(udat, list):
|
||
raise HTTPException(status_code=404, detail="Textblock nicht gefunden")
|
||
|
||
target = None
|
||
for x in udat:
|
||
if isinstance(x, dict) and x.get("id") == tb_id:
|
||
target = x
|
||
break
|
||
if target is None:
|
||
raise HTTPException(status_code=404, detail="Textblock nicht gefunden")
|
||
|
||
if "title" in body:
|
||
title = _clip_text(body.get("title", ""), _TEXTBLOCK_MAX_TITLE).strip()
|
||
if not title:
|
||
title = "Textblock"
|
||
target["title"] = title
|
||
if "body" in body:
|
||
content = _clip_text(body.get("body", ""), _TEXTBLOCK_MAX_BODY)
|
||
if not content.strip():
|
||
raise HTTPException(status_code=400, detail="body darf nicht leer sein")
|
||
target["body"] = content
|
||
if "sort_order" in body:
|
||
try:
|
||
target["sort_order"] = int(body.get("sort_order") or 0)
|
||
except Exception:
|
||
pass
|
||
target["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
|
||
_save_textblocks_store(store)
|
||
return JSONResponse(content={"success": True, "textblock": _public_textblock(target)})
|
||
|
||
|
||
@router.delete("/textblocks/{tb_id}")
|
||
async def empfang_textblocks_delete(tb_id: str, request: Request):
|
||
"""Loescht einen eigenen Textblock dauerhaft."""
|
||
s = _require_session(request)
|
||
pid = (s.get("practice_id") or "").strip()
|
||
uid = (s.get("user_id") or "").strip()
|
||
if not pid or not uid:
|
||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||
|
||
store = _load_textblocks_store()
|
||
udat = store.get(pid, {}).get(uid)
|
||
if not isinstance(udat, list):
|
||
return JSONResponse(content={"success": True, "removed": 0})
|
||
|
||
before = len(udat)
|
||
new_list = [x for x in udat if not (isinstance(x, dict) and x.get("id") == tb_id)]
|
||
removed = before - len(new_list)
|
||
store[pid][uid] = new_list
|
||
if removed:
|
||
_save_textblocks_store(store)
|
||
return JSONResponse(content={"success": True, "removed": removed})
|
||
|
||
|
||
# =====================================================================
|
||
# HTML PAGE
|
||
# =====================================================================
|
||
|
||
_HTML_NO_CACHE = {
|
||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||
"Pragma": "no-cache",
|
||
"Expires": "0",
|
||
"Content-Security-Policy": "frame-ancestors *;",
|
||
}
|
||
|
||
_LOGO_CACHE = {
|
||
"Cache-Control": "public, max-age=86400",
|
||
}
|
||
|
||
|
||
@router.get("/aza_logo.png")
|
||
async def empfang_logo_png():
|
||
"""Statisches Logo fuer Nav-Leiste (liegt neben empfang.html in web/)."""
|
||
p = Path(__file__).resolve().parent / "web" / "aza_logo.png"
|
||
if p.is_file():
|
||
return FileResponse(
|
||
path=p,
|
||
media_type="image/png",
|
||
headers=_LOGO_CACHE,
|
||
)
|
||
raise HTTPException(status_code=404, detail="aza_logo.png nicht gefunden")
|
||
|
||
|
||
@router.get("/favicon.ico")
|
||
async def empfang_favicon_ico():
|
||
"""Favicon / Taskleisten-Browser-Tab; projekt-root logo.ico (Windows-ICO)."""
|
||
p = Path(__file__).resolve().parent / "logo.ico"
|
||
if p.is_file():
|
||
return FileResponse(
|
||
path=p,
|
||
media_type="image/x-icon",
|
||
headers=_LOGO_CACHE,
|
||
)
|
||
raise HTTPException(status_code=404, detail="favicon.ico nicht gefunden")
|
||
|
||
|
||
# =====================================================================
|
||
# Download der installierbaren Empfang-Chat-Huelle (aza_empfang_chat_setup.exe)
|
||
# =====================================================================
|
||
#
|
||
# Public-Endpoint OHNE Auth: Die EXE-Datei selbst gibt keinen Zugriff auf
|
||
# Praxisdaten. Schutz entsteht erst durch:
|
||
# * gueltige Empfang-Session (Login),
|
||
# * Browser-Handoff-Code aus dem Empfang-Browser,
|
||
# * Praxis-Einladungscode CHAT-XXXX-XXXX.
|
||
# Der UI-Link wird im Browser-Empfang nur in praxis-aktiver Session
|
||
# eingeblendet, aber der Endpunkt selbst ist absichtlich nicht
|
||
# session-gesperrt, damit Benutzer den Installer auch ueber direkten
|
||
# Link aus Anleitungen herunterladen koennen.
|
||
#
|
||
# Keine Tokens in der URL, keine Praxisdaten im Log. Datei wird aus dem
|
||
# Produktions-Release-Ordner ausgeliefert, mit Dev-Fallback.
|
||
_DOWNLOAD_CACHE = {
|
||
"Cache-Control": "public, max-age=300", # 5 min: schnelle Updates moeglich
|
||
}
|
||
|
||
|
||
def _empfang_chat_setup_candidate_paths() -> list[Path]:
|
||
"""Liste plausibler Quellpfade fuer aza_empfang_chat_setup.exe.
|
||
|
||
Reihenfolge:
|
||
1. <projektroot>/release/downloads/aza_empfang_chat_setup.exe (Produktion)
|
||
2. <projektroot>/dist/installer/aza_empfang_chat_setup.exe (Dev)
|
||
"""
|
||
project_root = Path(__file__).resolve().parent
|
||
return [
|
||
project_root / "release" / "downloads" / "aza_empfang_chat_setup.exe",
|
||
project_root / "dist" / "installer" / "aza_empfang_chat_setup.exe",
|
||
]
|
||
|
||
|
||
@router.get("/downloads/aza_empfang_chat_setup.exe")
|
||
async def empfang_chat_setup_download():
|
||
"""Liefert die aktuelle Empfang-Chat-Huelle-Installer-EXE (kein Auth).
|
||
|
||
Datei-Inhalt wird NICHT geloggt. Es wird nur Pfad/Groesse vermerkt.
|
||
"""
|
||
for p in _empfang_chat_setup_candidate_paths():
|
||
try:
|
||
if p.is_file():
|
||
try:
|
||
_log.info(
|
||
"AZA_EMPFANG_CHAT_SETUP_DOWNLOAD source=%s size=%d",
|
||
p.name, p.stat().st_size,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return FileResponse(
|
||
path=p,
|
||
media_type="application/vnd.microsoft.portable-executable",
|
||
filename="aza_empfang_chat_setup.exe",
|
||
headers=_DOWNLOAD_CACHE,
|
||
)
|
||
except OSError:
|
||
continue
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="aza_empfang_chat_setup.exe nicht verfuegbar - bitte spaeter erneut versuchen",
|
||
)
|
||
|
||
|
||
@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=_HTML_NO_CACHE,
|
||
)
|
||
return HTMLResponse(content="<h1>empfang.html nicht gefunden</h1>", status_code=404)
|
||
|
||
|
||
@router.get("/chatwin.html", response_class=HTMLResponse)
|
||
async def empfang_chatwin_page():
|
||
"""Kompaktes Chat-Fenster (neues Fenster / Tab), z. B. 1:1 oder Allgemein."""
|
||
html_path = Path(__file__).resolve().parent / "web" / "empfang_chat_minimal.html"
|
||
if html_path.is_file():
|
||
return HTMLResponse(
|
||
content=html_path.read_text(encoding="utf-8"),
|
||
headers=_HTML_NO_CACHE,
|
||
)
|
||
return HTMLResponse(content="<h1>chatwin nicht gefunden</h1>", status_code=404)
|