767 lines
26 KiB
Python
767 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
AZA Empfang - Backend-Routen V4.
|
||
Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben.
|
||
Alle Daten practice-scoped. Backend ist die einzige Wahrheit.
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import secrets
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response
|
||
from fastapi.responses import HTMLResponse, JSONResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
router = APIRouter()
|
||
|
||
_DATA_DIR = Path(__file__).resolve().parent / "data"
|
||
_EMPFANG_FILE = _DATA_DIR / "empfang_nachrichten.json"
|
||
_PRACTICES_FILE = _DATA_DIR / "empfang_practices.json"
|
||
_ACCOUNTS_FILE = _DATA_DIR / "empfang_accounts.json"
|
||
_SESSIONS_FILE = _DATA_DIR / "empfang_sessions.json"
|
||
_TASKS_FILE = _DATA_DIR / "empfang_tasks.json"
|
||
|
||
DEFAULT_PRACTICE_ID = "default"
|
||
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage
|
||
|
||
|
||
def _ensure_data_dir():
|
||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
# =====================================================================
|
||
# JSON helpers (atomic write)
|
||
# =====================================================================
|
||
|
||
def _load_json(path: Path, default=None):
|
||
if not path.is_file():
|
||
return default if default is not None else []
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return default if default is not None else []
|
||
|
||
|
||
def _save_json(path: Path, data):
|
||
_ensure_data_dir()
|
||
tmp = str(path) + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
os.replace(tmp, str(path))
|
||
|
||
|
||
# =====================================================================
|
||
# Password hashing (PBKDF2 – no external dependency)
|
||
# =====================================================================
|
||
|
||
def _hash_password(password: str, salt: str = None) -> tuple[str, str]:
|
||
salt = salt or secrets.token_hex(16)
|
||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000)
|
||
return dk.hex(), salt
|
||
|
||
|
||
def _verify_password(password: str, stored_hash: str, salt: str) -> bool:
|
||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000)
|
||
return hmac.compare_digest(dk.hex(), stored_hash)
|
||
|
||
|
||
# =====================================================================
|
||
# Practices
|
||
# =====================================================================
|
||
|
||
def _load_practices() -> dict:
|
||
return _load_json(_PRACTICES_FILE, {})
|
||
|
||
|
||
def _save_practices(data: dict):
|
||
_save_json(_PRACTICES_FILE, data)
|
||
|
||
|
||
def _ensure_default_practice():
|
||
practices = _load_practices()
|
||
if DEFAULT_PRACTICE_ID not in practices:
|
||
practices[DEFAULT_PRACTICE_ID] = {
|
||
"practice_id": DEFAULT_PRACTICE_ID,
|
||
"name": "Meine Praxis",
|
||
"invite_code": secrets.token_urlsafe(8),
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_save_practices(practices)
|
||
_migrate_old_users(DEFAULT_PRACTICE_ID)
|
||
return practices[DEFAULT_PRACTICE_ID]
|
||
|
||
|
||
def _migrate_old_users(practice_id: str):
|
||
"""Migriert alte empfang_users.json Strings zu echten Accounts."""
|
||
old_file = _DATA_DIR / "empfang_users.json"
|
||
if not old_file.is_file():
|
||
return
|
||
try:
|
||
names = json.loads(old_file.read_text(encoding="utf-8"))
|
||
if not isinstance(names, list):
|
||
return
|
||
accounts = _load_accounts()
|
||
for name in names:
|
||
name = name.strip()
|
||
if not name:
|
||
continue
|
||
exists = any(
|
||
a["display_name"] == name and a["practice_id"] == practice_id
|
||
for a in accounts.values()
|
||
)
|
||
if not exists:
|
||
uid = uuid.uuid4().hex[:12]
|
||
pw_hash, pw_salt = _hash_password(name.lower())
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": practice_id,
|
||
"display_name": name,
|
||
"role": "mpa",
|
||
"pw_hash": pw_hash,
|
||
"pw_salt": pw_salt,
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_save_accounts(accounts)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# =====================================================================
|
||
# Accounts (practice-scoped users with auth)
|
||
# =====================================================================
|
||
|
||
def _load_accounts() -> dict:
|
||
return _load_json(_ACCOUNTS_FILE, {})
|
||
|
||
|
||
def _save_accounts(data: dict):
|
||
_save_json(_ACCOUNTS_FILE, data)
|
||
|
||
|
||
def _practice_users(practice_id: str) -> list[dict]:
|
||
accounts = _load_accounts()
|
||
return [
|
||
{"user_id": a["user_id"], "display_name": a["display_name"], "role": a["role"]}
|
||
for a in accounts.values()
|
||
if a.get("practice_id") == practice_id
|
||
]
|
||
|
||
|
||
# =====================================================================
|
||
# Sessions
|
||
# =====================================================================
|
||
|
||
def _load_sessions() -> dict:
|
||
return _load_json(_SESSIONS_FILE, {})
|
||
|
||
|
||
def _save_sessions(data: dict):
|
||
_save_json(_SESSIONS_FILE, data)
|
||
|
||
|
||
def _create_session(user_id: str, practice_id: str, display_name: str, role: str) -> str:
|
||
token = secrets.token_urlsafe(32)
|
||
sessions = _load_sessions()
|
||
sessions[token] = {
|
||
"user_id": user_id,
|
||
"practice_id": practice_id,
|
||
"display_name": display_name,
|
||
"role": role,
|
||
"created": time.time(),
|
||
"last_active": time.time(),
|
||
}
|
||
_save_sessions(sessions)
|
||
return token
|
||
|
||
|
||
def _get_session(token: str) -> Optional[dict]:
|
||
if not token:
|
||
return None
|
||
sessions = _load_sessions()
|
||
s = sessions.get(token)
|
||
if not s:
|
||
return None
|
||
if time.time() - s.get("created", 0) > SESSION_MAX_AGE:
|
||
del sessions[token]
|
||
_save_sessions(sessions)
|
||
return None
|
||
s["last_active"] = time.time()
|
||
sessions[token] = s
|
||
_save_sessions(sessions)
|
||
return s
|
||
|
||
|
||
def _delete_session(token: str):
|
||
sessions = _load_sessions()
|
||
if token in sessions:
|
||
del sessions[token]
|
||
_save_sessions(sessions)
|
||
|
||
|
||
def _session_from_request(request: Request) -> Optional[dict]:
|
||
token = request.cookies.get("aza_session") or ""
|
||
if not token:
|
||
auth = request.headers.get("Authorization", "")
|
||
if auth.startswith("Bearer "):
|
||
token = auth[7:]
|
||
if not token:
|
||
token = request.query_params.get("session_token", "")
|
||
return _get_session(token)
|
||
|
||
|
||
def _require_session(request: Request) -> dict:
|
||
s = _session_from_request(request)
|
||
if not s:
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
return s
|
||
|
||
|
||
# =====================================================================
|
||
# Messages (practice-scoped)
|
||
# =====================================================================
|
||
|
||
def _load_messages() -> list[dict]:
|
||
return _load_json(_EMPFANG_FILE, [])
|
||
|
||
|
||
def _save_messages(messages: list[dict]):
|
||
_save_json(_EMPFANG_FILE, messages)
|
||
|
||
|
||
def _msg_practice(m: dict) -> str:
|
||
return m.get("practice_id") or DEFAULT_PRACTICE_ID
|
||
|
||
|
||
def _filter_by_practice(messages: list[dict], pid: str) -> list[dict]:
|
||
return [m for m in messages if _msg_practice(m) == pid]
|
||
|
||
|
||
# =====================================================================
|
||
# Tasks (practice-scoped, server-side)
|
||
# =====================================================================
|
||
|
||
def _load_tasks() -> list[dict]:
|
||
return _load_json(_TASKS_FILE, [])
|
||
|
||
|
||
def _save_tasks(tasks: list[dict]):
|
||
_save_json(_TASKS_FILE, tasks)
|
||
|
||
|
||
# =====================================================================
|
||
# AUTH ENDPOINTS
|
||
# =====================================================================
|
||
|
||
@router.post("/auth/setup")
|
||
async def auth_setup(request: Request):
|
||
"""Erstellt den ersten Admin-Benutzer fuer die Default-Praxis.
|
||
Nur aufrufbar wenn noch keine Accounts existieren."""
|
||
practice = _ensure_default_practice()
|
||
accounts = _load_accounts()
|
||
practice_accounts = [a for a in accounts.values()
|
||
if a.get("practice_id") == DEFAULT_PRACTICE_ID]
|
||
if practice_accounts:
|
||
raise HTTPException(status_code=409,
|
||
detail="Setup bereits abgeschlossen. Bitte Login verwenden.")
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
name = (body.get("name") or "").strip()
|
||
password = (body.get("password") or "").strip()
|
||
if not name or not password or len(password) < 4:
|
||
raise HTTPException(status_code=400,
|
||
detail="Name und Passwort (min. 4 Zeichen) erforderlich")
|
||
uid = uuid.uuid4().hex[:12]
|
||
pw_hash, pw_salt = _hash_password(password)
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": DEFAULT_PRACTICE_ID,
|
||
"display_name": name,
|
||
"role": "admin",
|
||
"pw_hash": pw_hash,
|
||
"pw_salt": pw_salt,
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_save_accounts(accounts)
|
||
token = _create_session(uid, DEFAULT_PRACTICE_ID, name, "admin")
|
||
resp = JSONResponse(content={
|
||
"success": True, "user_id": uid, "role": "admin",
|
||
"display_name": name, "practice_id": DEFAULT_PRACTICE_ID,
|
||
"invite_code": practice.get("invite_code", ""),
|
||
})
|
||
resp.set_cookie("aza_session", token, httponly=True, samesite="lax",
|
||
max_age=SESSION_MAX_AGE)
|
||
return resp
|
||
|
||
|
||
@router.post("/auth/login")
|
||
async def auth_login(request: Request):
|
||
"""Login mit Name + Passwort."""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
name = (body.get("name") or "").strip()
|
||
password = (body.get("password") or "").strip()
|
||
pid = (body.get("practice_id") or "").strip() or DEFAULT_PRACTICE_ID
|
||
if not name or not password:
|
||
raise HTTPException(status_code=400, detail="Name und Passwort erforderlich")
|
||
accounts = _load_accounts()
|
||
target = None
|
||
for a in accounts.values():
|
||
if a["display_name"] == name and a.get("practice_id") == pid:
|
||
target = a
|
||
break
|
||
if not target:
|
||
raise HTTPException(status_code=401, detail="Benutzer nicht gefunden")
|
||
if not _verify_password(password, target["pw_hash"], target["pw_salt"]):
|
||
raise HTTPException(status_code=401, detail="Falsches Passwort")
|
||
token = _create_session(target["user_id"], pid, name, target["role"])
|
||
resp = JSONResponse(content={
|
||
"success": True, "user_id": target["user_id"], "role": target["role"],
|
||
"display_name": name, "practice_id": pid,
|
||
})
|
||
resp.set_cookie("aza_session", token, httponly=True, samesite="lax",
|
||
max_age=SESSION_MAX_AGE)
|
||
return resp
|
||
|
||
|
||
@router.post("/auth/register")
|
||
async def auth_register(request: Request):
|
||
"""Neuen Benutzer registrieren mit Einladungscode."""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
invite_code = (body.get("invite_code") or "").strip()
|
||
name = (body.get("name") or "").strip()
|
||
password = (body.get("password") or "").strip()
|
||
role = (body.get("role") or "mpa").strip()
|
||
if not invite_code or not name or not password or len(password) < 4:
|
||
raise HTTPException(status_code=400,
|
||
detail="Einladungscode, Name und Passwort (min. 4 Zeichen) erforderlich")
|
||
if role not in ("admin", "arzt", "mpa", "empfang"):
|
||
role = "mpa"
|
||
practices = _load_practices()
|
||
target_pid = None
|
||
for pid, p in practices.items():
|
||
if p.get("invite_code") == invite_code:
|
||
target_pid = pid
|
||
break
|
||
if not target_pid:
|
||
raise HTTPException(status_code=403, detail="Ungueltiger Einladungscode")
|
||
accounts = _load_accounts()
|
||
exists = any(a["display_name"] == name and a.get("practice_id") == target_pid
|
||
for a in accounts.values())
|
||
if exists:
|
||
raise HTTPException(status_code=409, detail="Benutzername bereits vergeben")
|
||
uid = uuid.uuid4().hex[:12]
|
||
pw_hash, pw_salt = _hash_password(password)
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": target_pid,
|
||
"display_name": name,
|
||
"role": role,
|
||
"pw_hash": pw_hash,
|
||
"pw_salt": pw_salt,
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_save_accounts(accounts)
|
||
token = _create_session(uid, target_pid, name, role)
|
||
resp = JSONResponse(content={
|
||
"success": True, "user_id": uid, "role": role,
|
||
"display_name": name, "practice_id": target_pid,
|
||
})
|
||
resp.set_cookie("aza_session", token, httponly=True, samesite="lax",
|
||
max_age=SESSION_MAX_AGE)
|
||
return resp
|
||
|
||
|
||
@router.get("/auth/me")
|
||
async def auth_me(request: Request):
|
||
"""Aktuelle Session pruefen. Liefert User-Daten oder 401."""
|
||
s = _session_from_request(request)
|
||
if not s:
|
||
return JSONResponse(status_code=401, content={"authenticated": False})
|
||
return JSONResponse(content={
|
||
"authenticated": True,
|
||
"user_id": s["user_id"],
|
||
"display_name": s["display_name"],
|
||
"role": s["role"],
|
||
"practice_id": s["practice_id"],
|
||
})
|
||
|
||
|
||
@router.post("/auth/logout")
|
||
async def auth_logout(request: Request):
|
||
token = request.cookies.get("aza_session", "")
|
||
_delete_session(token)
|
||
resp = JSONResponse(content={"success": True})
|
||
resp.delete_cookie("aza_session")
|
||
return resp
|
||
|
||
|
||
@router.get("/auth/needs_setup")
|
||
async def auth_needs_setup():
|
||
"""Pruefen ob Setup noetig ist (keine Accounts vorhanden)."""
|
||
_ensure_default_practice()
|
||
accounts = _load_accounts()
|
||
has_accounts = any(a.get("practice_id") == DEFAULT_PRACTICE_ID
|
||
for a in accounts.values())
|
||
practices = _load_practices()
|
||
invite_code = practices.get(DEFAULT_PRACTICE_ID, {}).get("invite_code", "")
|
||
return JSONResponse(content={
|
||
"needs_setup": not has_accounts,
|
||
"invite_code": invite_code if not has_accounts else "",
|
||
})
|
||
|
||
|
||
# =====================================================================
|
||
# USER MANAGEMENT (admin only for invite/role changes)
|
||
# =====================================================================
|
||
|
||
@router.get("/users")
|
||
async def empfang_users(request: Request):
|
||
"""Liefert Benutzer der Praxis. Offen fuer alle authentifizierten + Legacy."""
|
||
s = _session_from_request(request)
|
||
if s:
|
||
users = _practice_users(s["practice_id"])
|
||
return JSONResponse(content={
|
||
"users": [u["display_name"] for u in users],
|
||
"users_full": users,
|
||
"practice_id": s["practice_id"],
|
||
})
|
||
pid = request.query_params.get("practice_id", "") or DEFAULT_PRACTICE_ID
|
||
users = _practice_users(pid)
|
||
if users:
|
||
return JSONResponse(content={
|
||
"users": [u["display_name"] for u in users],
|
||
"practice_id": pid,
|
||
})
|
||
old_file = _DATA_DIR / "empfang_users.json"
|
||
if old_file.is_file():
|
||
try:
|
||
names = json.loads(old_file.read_text(encoding="utf-8"))
|
||
if isinstance(names, list):
|
||
return JSONResponse(content={"users": names, "practice_id": pid})
|
||
except Exception:
|
||
pass
|
||
return JSONResponse(content={"users": [], "practice_id": pid})
|
||
|
||
|
||
@router.post("/users")
|
||
async def empfang_register_user(request: Request):
|
||
"""Legacy-kompatibel: Benutzer anlegen/umbenennen/loeschen."""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
name = (body.get("name") or "").strip()
|
||
action = (body.get("action") or "add").strip()
|
||
pid = DEFAULT_PRACTICE_ID
|
||
s = _session_from_request(request)
|
||
if s:
|
||
pid = s["practice_id"]
|
||
if not name:
|
||
return JSONResponse(content={"success": False})
|
||
accounts = _load_accounts()
|
||
if action == "delete":
|
||
to_del = [uid for uid, a in accounts.items()
|
||
if a["display_name"] == name and a.get("practice_id") == pid]
|
||
for uid in to_del:
|
||
del accounts[uid]
|
||
_save_accounts(accounts)
|
||
elif action == "rename":
|
||
new_name = (body.get("new_name") or "").strip()
|
||
if new_name:
|
||
for a in accounts.values():
|
||
if a["display_name"] == name and a.get("practice_id") == pid:
|
||
a["display_name"] = new_name
|
||
_save_accounts(accounts)
|
||
else:
|
||
exists = any(a["display_name"] == name and a.get("practice_id") == pid
|
||
for a in accounts.values())
|
||
if not exists:
|
||
uid = uuid.uuid4().hex[:12]
|
||
pw_hash, pw_salt = _hash_password(name.lower())
|
||
accounts[uid] = {
|
||
"user_id": uid,
|
||
"practice_id": pid,
|
||
"display_name": name,
|
||
"role": "mpa",
|
||
"pw_hash": pw_hash,
|
||
"pw_salt": pw_salt,
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_save_accounts(accounts)
|
||
users = _practice_users(pid)
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"users": [u["display_name"] for u in users],
|
||
"practice_id": pid,
|
||
})
|
||
|
||
|
||
# =====================================================================
|
||
# MESSAGE ROUTES (practice-scoped)
|
||
# =====================================================================
|
||
|
||
class EmpfangMessage(BaseModel):
|
||
medikamente: str = ""
|
||
therapieplan: str = ""
|
||
procedere: str = ""
|
||
kommentar: str = ""
|
||
patient: str = ""
|
||
absender: str = ""
|
||
zeitstempel: str = ""
|
||
practice_id: str = ""
|
||
extras: dict = Field(default_factory=dict)
|
||
|
||
|
||
@router.post("/send")
|
||
async def empfang_send(msg: EmpfangMessage, request: Request):
|
||
s = _session_from_request(request)
|
||
pid = msg.practice_id.strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID)
|
||
absender = msg.absender.strip()
|
||
if s and not absender:
|
||
absender = s["display_name"]
|
||
|
||
msg_id = uuid.uuid4().hex[:12]
|
||
messages = _load_messages()
|
||
|
||
thread_id = msg_id
|
||
reply_to = (msg.extras or {}).get("reply_to", "")
|
||
if reply_to:
|
||
for m in messages:
|
||
if m.get("id") == reply_to:
|
||
thread_id = m.get("thread_id", reply_to)
|
||
break
|
||
else:
|
||
thread_id = reply_to
|
||
|
||
entry = {
|
||
"id": msg_id,
|
||
"thread_id": thread_id,
|
||
"practice_id": pid,
|
||
"medikamente": msg.medikamente.strip(),
|
||
"therapieplan": msg.therapieplan.strip(),
|
||
"procedere": msg.procedere.strip(),
|
||
"kommentar": msg.kommentar.strip(),
|
||
"patient": msg.patient.strip(),
|
||
"absender": absender,
|
||
"zeitstempel": msg.zeitstempel.strip() or time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"empfangen": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"status": "offen",
|
||
"user_id": s["user_id"] if s else "",
|
||
}
|
||
if msg.extras:
|
||
entry["extras"] = msg.extras
|
||
|
||
messages.insert(0, entry)
|
||
_save_messages(messages)
|
||
|
||
return JSONResponse(content={
|
||
"success": True, "id": msg_id, "thread_id": thread_id,
|
||
"practice_id": pid,
|
||
})
|
||
|
||
|
||
@router.get("/messages")
|
||
async def empfang_list(request: Request, practice_id: Optional[str] = Query(None)):
|
||
s = _session_from_request(request)
|
||
pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID)
|
||
messages = _load_messages()
|
||
filtered = _filter_by_practice(messages, pid)
|
||
return JSONResponse(content={"success": True, "messages": filtered})
|
||
|
||
|
||
@router.get("/thread/{thread_id}")
|
||
async def empfang_thread(thread_id: str, request: Request,
|
||
practice_id: Optional[str] = Query(None)):
|
||
s = _session_from_request(request)
|
||
pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID)
|
||
messages = _load_messages()
|
||
thread = [m for m in messages
|
||
if m.get("thread_id") == thread_id and _msg_practice(m) == pid]
|
||
thread.sort(key=lambda m: m.get("empfangen", ""))
|
||
return JSONResponse(content={"success": True, "messages": thread})
|
||
|
||
|
||
@router.post("/messages/{msg_id}/done")
|
||
async def empfang_done(msg_id: str):
|
||
messages = _load_messages()
|
||
target = next((m for m in messages if m.get("id") == msg_id), None)
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
|
||
tid = target.get("thread_id", msg_id)
|
||
pid = _msg_practice(target)
|
||
for m in messages:
|
||
if m.get("thread_id") == tid and _msg_practice(m) == pid:
|
||
m["status"] = "erledigt"
|
||
_save_messages(messages)
|
||
return JSONResponse(content={"success": True})
|
||
|
||
|
||
@router.delete("/messages/{msg_id}")
|
||
async def empfang_delete(msg_id: str):
|
||
messages = _load_messages()
|
||
target = next((m for m in messages if m.get("id") == msg_id), None)
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
|
||
tid = target.get("thread_id", msg_id)
|
||
pid = _msg_practice(target)
|
||
if tid == msg_id:
|
||
new = [m for m in messages
|
||
if not (m.get("thread_id", m.get("id")) == msg_id and _msg_practice(m) == pid)
|
||
and not (m.get("id") == msg_id)]
|
||
else:
|
||
new = [m for m in messages if m.get("id") != msg_id]
|
||
_save_messages(new)
|
||
return JSONResponse(content={"success": True})
|
||
|
||
|
||
# =====================================================================
|
||
# TASKS (practice-scoped, server-side)
|
||
# =====================================================================
|
||
|
||
@router.get("/tasks")
|
||
async def empfang_tasks_list(request: Request):
|
||
s = _session_from_request(request)
|
||
pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID
|
||
tasks = _load_tasks()
|
||
filtered = [t for t in tasks if t.get("practice_id", DEFAULT_PRACTICE_ID) == pid]
|
||
return JSONResponse(content={"success": True, "tasks": filtered})
|
||
|
||
|
||
@router.post("/tasks")
|
||
async def empfang_tasks_create(request: Request):
|
||
s = _session_from_request(request)
|
||
pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
text = (body.get("text") or "").strip()
|
||
if not text:
|
||
raise HTTPException(status_code=400, detail="Text erforderlich")
|
||
task = {
|
||
"task_id": uuid.uuid4().hex[:12],
|
||
"practice_id": pid,
|
||
"text": text,
|
||
"done": False,
|
||
"assignee": (body.get("assignee") or "").strip(),
|
||
"created_by": s["display_name"] if s else "",
|
||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
tasks = _load_tasks()
|
||
tasks.insert(0, task)
|
||
_save_tasks(tasks)
|
||
return JSONResponse(content={"success": True, "task": task})
|
||
|
||
|
||
@router.post("/tasks/{task_id}/update")
|
||
async def empfang_tasks_update(task_id: str, request: Request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
tasks = _load_tasks()
|
||
target = next((t for t in tasks if t.get("task_id") == task_id), None)
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="Aufgabe nicht gefunden")
|
||
if "done" in body:
|
||
target["done"] = bool(body["done"])
|
||
if "text" in body:
|
||
target["text"] = (body["text"] or "").strip() or target["text"]
|
||
if "assignee" in body:
|
||
target["assignee"] = (body["assignee"] or "").strip()
|
||
_save_tasks(tasks)
|
||
return JSONResponse(content={"success": True, "task": target})
|
||
|
||
|
||
@router.delete("/tasks/{task_id}")
|
||
async def empfang_tasks_delete(task_id: str):
|
||
tasks = _load_tasks()
|
||
tasks = [t for t in tasks if t.get("task_id") != task_id]
|
||
_save_tasks(tasks)
|
||
return JSONResponse(content={"success": True})
|
||
|
||
|
||
# =====================================================================
|
||
# CLEANUP + PRACTICE INFO
|
||
# =====================================================================
|
||
|
||
@router.post("/cleanup")
|
||
async def empfang_cleanup(request: Request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
max_days = int(body.get("max_age_days", 30))
|
||
s = _session_from_request(request)
|
||
pid = (body.get("practice_id") or "").strip() or (
|
||
s["practice_id"] if s else DEFAULT_PRACTICE_ID)
|
||
cutoff = time.strftime(
|
||
"%Y-%m-%d %H:%M:%S",
|
||
time.localtime(time.time() - max_days * 86400),
|
||
)
|
||
messages = _load_messages()
|
||
before = len(messages)
|
||
kept = [
|
||
m for m in messages
|
||
if _msg_practice(m) != pid
|
||
or (m.get("empfangen") or m.get("zeitstempel", "")) >= cutoff
|
||
]
|
||
removed = before - len(kept)
|
||
if removed > 0:
|
||
_save_messages(kept)
|
||
return JSONResponse(content={
|
||
"success": True, "removed": removed, "remaining": len(kept),
|
||
})
|
||
|
||
|
||
@router.get("/practice/info")
|
||
async def empfang_practice_info(request: Request):
|
||
s = _session_from_request(request)
|
||
pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID
|
||
users = _practice_users(pid)
|
||
messages = _filter_by_practice(_load_messages(), pid)
|
||
open_count = sum(1 for m in messages if m.get("status") == "offen")
|
||
practices = _load_practices()
|
||
p = practices.get(pid, {})
|
||
result = {
|
||
"practice_id": pid,
|
||
"practice_name": p.get("name", ""),
|
||
"user_count": len(users),
|
||
"message_count": len(messages),
|
||
"open_count": open_count,
|
||
}
|
||
if s and s.get("role") == "admin":
|
||
result["invite_code"] = p.get("invite_code", "")
|
||
return JSONResponse(content=result)
|
||
|
||
|
||
# =====================================================================
|
||
# HTML PAGE
|
||
# =====================================================================
|
||
|
||
@router.get("/", response_class=HTMLResponse)
|
||
async def empfang_page(request: Request):
|
||
html_path = Path(__file__).resolve().parent / "web" / "empfang.html"
|
||
if html_path.is_file():
|
||
return HTMLResponse(
|
||
content=html_path.read_text(encoding="utf-8"),
|
||
headers={"Cache-Control": "no-cache, no-store, must-revalidate",
|
||
"Pragma": "no-cache", "Expires": "0"},
|
||
)
|
||
return HTMLResponse(content="<h1>empfang.html nicht gefunden</h1>", status_code=404)
|