From eb57a6eccfc3aac64f801cda54bf6af4aa776474 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 18 Jul 2026 10:57:58 +0200 Subject: [PATCH] Admin-Dashboard: Konten sperren/loeschen, Audit-Log, DSGVO-Export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Seite admindash (pgAdminDash) mit Kontenuebersicht: Status, Patientenanzahl, Sperren/Entsperren/Loeschen mit Pflicht-Begruendung - "Loeschen" ist bewusst Soft-Delete: Konto wird gesperrt (aktiv=0) und alle aktiven Patienten werden ueber die bestehende Archivierungs- Logik archiviert (10/30 Jahre Aufbewahrungsfrist, § 630f/§ 199 BGB) statt physisch geloescht zu werden - Bestehende Hard-Delete-Route (DELETE /api/nutzer/) lehnt jetzt ab, solange das Konto noch Patienten besitzt - schliesst die Luecke verwaister Patientendatensaetze - nutzer.aktiv wird bei jedem Request geprueft -> Sperren wirkt sofort, auch bei bereits offener Sitzung, ohne zusaetzlichen Session-Store - Neue Tabelle admin_audit_log (unveraenderlich, analog notizen_verlauf): jede Sperr-/Entsperr-/Loesch-/Export-Aktion mit Admin, Zeitstempel, Ziel und Pflicht-Begruendung protokolliert - DSGVO-Export (POST /api/admin/patienten//dsgvo-export): admin-only, verlangt Rechtsgrundlage/Aktenzeichen, liefert ZIP mit allen Datensaetzen (daten.json) plus Dokumenten/Briefen/Fotos, in-memory ohne Temp-Dateien - Selbst-Sperrung/-Loeschung durch den handelnden Admin blockiert (kein Lockout-Risiko) - GET /api/nutzer liefert jetzt zusaetzlich patienten_anzahl Verifiziert: API-Tests fuer alle Guards (Begruendung-Pflicht, Self-Lock- Block, Hard-Delete-Block bei zugeordneten Patienten, sofortige Session- Sperrung, 403 fuer Nicht-Admins), ZIP-Inhalt geprueft, Audit-Log-Eintraege korrekt, Dashboard + Modal per Playwright-Screenshot visuell verifiziert. Dritter und letzter Baustein aus refactoring.md. --- CLAUDE.md | 10 +- pflegepilot_alpha_v2/pflegepilot/app.py | 205 ++++++++++++++++-- .../pflegepilot/templates/index.html | 113 +++++++++- 3 files changed, 309 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d90334d..d1b6055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,9 +52,15 @@ The product owner specified a pivot away from the original **central multi-patie 1. **Per-account patient isolation** — ✅ implemented. Data model: 1 `nutzer` = 1 account, `patienten.nutzer_id` FK, `admin` role exempt (sees everything). See "Patient isolation" above for the enforcement mechanism. Verified end-to-end (two non-admin accounts cannot see or directly access each other's patients; admin sees all). 2. **No-AI interview flow** — ✅ implemented. `pgNeu()` in `templates/index.html` is now a 6-step guided wizard (`neuWiz` state object, `neuRender()`/`neuNext()`/`neuBack()`/`neuCollect()`) that fully replaced the old single-form dialog: persönliche Daten → Kontakt & Adresse → Krankenversicherung → Pflegesituation → Einschätzung & Pflegeperson → Übersicht. Step 4 asks 5 plain-language situational questions (`NEU_FRAGEN`) plus a Zielgruppe question (`NEU_ZG_OPTIONEN`); `neuSchaetzung()` sums the answers into a **rule-based, non-AI** Pflegegrad range shown with a prominent disclaimer that it's not an official determination (only the Medizinischer Dienst's Begutachtung is). Server enforces a hard cap of 3 patients per non-admin account in `api_neuer_patient` (admin exempt). -3. **Admin dashboard** — not started. Lock/delete accounts, and export archived patient data under lawful (attorney/court) order, with GDPR (DSGVO) compliance as a hard requirement. +3. **Admin dashboard** — ✅ implemented. New `admindash` page (`pgAdminDash()` in `templates/index.html`, nav item under `.sb-admin`), backed by `app.py` routes under `/api/admin/nutzer//{sperren,entsperren,loeschen}`, `/api/admin/audit-log`, `/api/admin/patienten//dsgvo-export`. Key design points, all confirmed with the user beforehand: + - **"Löschen" is a soft-delete**, not `DELETE FROM nutzer`: it sets `aktiv=0` and archives every active patient the account owns via `_archiviere_patient()` (same 10/30-year retention-deadline logic as the existing per-patient archive feature) — because care records carry a statutory retention duty (§ 630f BGB) that a hard delete would violate. The pre-existing hard-delete route (`DELETE /api/nutzer/`) still exists but now refuses (400) if the account still owns any patients, forcing admins through the archive-first path. + - **`nutzer.aktiv` is checked on every request** (`_get_current_user()`), so locking an account invalidates an already-open session immediately — no separate session-store/invalidation mechanism was needed. + - **Every action requires a `begruendung`** (400 without one) and is written to the new `admin_audit_log` table (`_log_admin_aktion()`) — immutable, insert-only, same audit-trail pattern as `notizen_verlauf`. This is the operator's evidentiary record for disputes. + - **DSGVO export** (`POST /api/admin/patienten//dsgvo-export`) requires a `begruendung` (used as Rechtsgrundlage/Aktenzeichen), is admin-only, and streams back a ZIP (built in-memory with `zipfile`/`io.BytesIO`, no temp files) containing `daten.json` (patient + antraege + leistungen + aufgaben + pflegetagebuch + dokumente + notizen_verlauf, plus an `export_info` block) and the actual files from `static/briefe//`, `static/dokumente//`, and the patient's photo. Every export is itself audit-logged. + - Self-targeting is blocked for sperren/loeschen (an admin can't lock/soft-delete their own account) to avoid a lockout with no recovery path. + - `GET /api/nutzer` now also returns `patienten_anzahl` per account (used by the dashboard table; backward-compatible addition). -The user has asked to be asked clarifying questions about architecture/data model before each piece of this refactor. Confirmed decisions so far: 1 nutzer = 1 account (no separate `familien` entity), admin bypasses isolation entirely, interview flow requires login (no public pre-account onboarding), interview fully replaced the old form rather than existing alongside it, 3-patient cap is hard-enforced server-side. Apply the same "ask before assuming" approach to the remaining admin-dashboard piece. +All three refactoring.md pivot pieces are now implemented. Verified end-to-end (API tests for every guard/happy-path + audit-log content, and a Playwright screenshot pass of the dashboard UI and the confirmation modal). ## Known constraints worth knowing before touching auth/security-sensitive code diff --git a/pflegepilot_alpha_v2/pflegepilot/app.py b/pflegepilot_alpha_v2/pflegepilot/app.py index d9fa687..f5aebe9 100644 --- a/pflegepilot_alpha_v2/pflegepilot/app.py +++ b/pflegepilot_alpha_v2/pflegepilot/app.py @@ -3,13 +3,13 @@ Pflegepilot Alpha – Flask + SQLite Web-App Vollständig mit 82-Leistungen-Katalog integriert """ import sqlite3, os, json, re, base64, urllib.request, urllib.error, time, functools -import smtplib +import smtplib, io, zipfile from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders from datetime import datetime, date, timedelta -from flask import Flask, render_template, request, jsonify, g, session +from flask import Flask, render_template, request, jsonify, g, session, Response from werkzeug.security import generate_password_hash, check_password_hash from katalog import (KATALOG, get_all, get_by_zielgruppe, get_by_pflegegrad, get_by_kategorie, get_kategorien, search, get_by_id, @@ -60,6 +60,17 @@ def init_db(): "ALTER TABLE patienten ADD COLUMN archiv_frist TEXT", "ALTER TABLE patienten ADD COLUMN archiv_hinweis TEXT", "ALTER TABLE patienten ADD COLUMN nutzer_id INTEGER REFERENCES nutzer(id)", + """CREATE TABLE IF NOT EXISTS admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER, + admin_name TEXT, + aktion TEXT NOT NULL, + ziel_typ TEXT NOT NULL, + ziel_id INTEGER, + ziel_bezeichnung TEXT, + begruendung TEXT NOT NULL, + erstellt_am TEXT DEFAULT (datetime('now','localtime')) + )""", """CREATE TABLE IF NOT EXISTS dokumente ( id INTEGER PRIMARY KEY AUTOINCREMENT, patient_id INTEGER NOT NULL REFERENCES patienten(id) ON DELETE CASCADE, @@ -155,6 +166,39 @@ def _get_owned_patient(pid): "SELECT * FROM patienten WHERE id=? AND nutzer_id=?", (pid, u["id"]) ).fetchone() +# ─── Admin-Dashboard: Audit-Log & Archivierungs-Helper ─────────────────────── +def _log_admin_aktion(aktion, ziel_typ, ziel_id, ziel_bezeichnung, begruendung): + """Unveränderlicher Audit-Trail für Admin-Aktionen (Sperren/Löschen/Export) – + Nachweis für den Betreiber, wer wann was mit welcher Begründung getan hat.""" + u = _get_current_user() + db = get_db() + db.execute(""" + INSERT INTO admin_audit_log (admin_id, admin_name, aktion, ziel_typ, ziel_id, ziel_bezeichnung, begruendung) + VALUES (?,?,?,?,?,?,?) + """, (u["id"] if u else None, (u["name"] or u["benutzername"]) if u else "System", + aktion, ziel_typ, ziel_id, ziel_bezeichnung, begruendung)) + db.commit() + +def _berechne_archiv_frist(status_datum, status): + # § 199 Abs. 2 BGB (30 Jahre Verstorbene) bzw. § 630f BGB (10 Jahre sonst) + jahre = 30 if status == "verstorben" else 10 + try: + dt = datetime.strptime(status_datum, "%Y-%m-%d") + return dt.replace(year=dt.year + jahre).strftime("%Y-%m-%d"), jahre + except Exception: + return "", jahre + +def _archiviere_patient(db, pid, status, hinweis, status_datum=None): + status_datum = status_datum or date.today().isoformat() + archiv_frist, jahre = _berechne_archiv_frist(status_datum, status) + db.execute(""" + UPDATE patienten + SET patient_status=?, status_datum=?, archiv_frist=?, archiv_hinweis=?, + geaendert_am=datetime('now','localtime') + WHERE id=? + """, (status, status_datum, archiv_frist, hinweis, pid)) + return archiv_frist, jahre + def login_required(f): @functools.wraps(f) def decorated(*args, **kwargs): @@ -233,7 +277,14 @@ def api_nutzer_list(): rows = db.execute( "SELECT id, benutzername, name, rolle, aktiv, erstellt_am FROM nutzer ORDER BY erstellt_am" ).fetchall() - return jsonify([dict(r) for r in rows]) + result = [] + for r in rows: + d = dict(r) + d["patienten_anzahl"] = db.execute( + "SELECT COUNT(*) FROM patienten WHERE nutzer_id=?", (r["id"],) + ).fetchone()[0] + result.append(d) + return jsonify(result) @app.route("/api/nutzer", methods=["POST"]) @admin_required @@ -274,10 +325,144 @@ def api_nutzer_delete(uid): if uid == session.get("nutzer_id"): return jsonify({"error": "Eigenen Account nicht löschbar"}), 400 db = get_db() + anzahl = db.execute("SELECT COUNT(*) FROM patienten WHERE nutzer_id=?", (uid,)).fetchone()[0] + if anzahl > 0: + return jsonify({ + "error": f"Konto hat noch {anzahl} zugeordnete Patient(en) – bitte zuerst über das Admin-Dashboard sperren/löschen." + }), 400 db.execute("DELETE FROM nutzer WHERE id=?", (uid,)) db.commit() return jsonify({"ok": True}) +# ─── API: Admin-Dashboard (Konten sperren/löschen, Audit-Log, DSGVO-Export) ── +# "Löschen" ist bewusst ein Soft-Delete: das Konto wird gesperrt und seine +# aktiven Patienten werden archiviert (mit der bestehenden Aufbewahrungsfrist- +# Logik), statt Datensätze physisch zu entfernen – Pflegeunterlagen unterliegen +# einer gesetzlichen Aufbewahrungspflicht (§ 630f BGB), die einem sofortigen +# Hard-Delete entgegensteht. Jede Aktion hier verlangt eine Begründung und wird +# unveränderlich in admin_audit_log protokolliert. +@app.route("/api/admin/nutzer//sperren", methods=["POST"]) +@admin_required +def api_admin_nutzer_sperren(uid): + if uid == session.get("nutzer_id"): + return jsonify({"error": "Eigenes Konto kann nicht gesperrt werden"}), 400 + grund = ((request.json or {}).get("begruendung") or "").strip() + if not grund: + return jsonify({"error": "Begründung erforderlich"}), 400 + db = get_db() + n = db.execute("SELECT benutzername FROM nutzer WHERE id=?", (uid,)).fetchone() + if not n: + return jsonify({"error": "Nicht gefunden"}), 404 + db.execute("UPDATE nutzer SET aktiv=0 WHERE id=?", (uid,)) + db.commit() + _log_admin_aktion("konto_gesperrt", "nutzer", uid, n["benutzername"], grund) + return jsonify({"ok": True}) + +@app.route("/api/admin/nutzer//entsperren", methods=["POST"]) +@admin_required +def api_admin_nutzer_entsperren(uid): + grund = ((request.json or {}).get("begruendung") or "").strip() + if not grund: + return jsonify({"error": "Begründung erforderlich"}), 400 + db = get_db() + n = db.execute("SELECT benutzername FROM nutzer WHERE id=?", (uid,)).fetchone() + if not n: + return jsonify({"error": "Nicht gefunden"}), 404 + db.execute("UPDATE nutzer SET aktiv=1 WHERE id=?", (uid,)) + db.commit() + _log_admin_aktion("konto_entsperrt", "nutzer", uid, n["benutzername"], grund) + return jsonify({"ok": True}) + +@app.route("/api/admin/nutzer//loeschen", methods=["POST"]) +@admin_required +def api_admin_nutzer_loeschen(uid): + if uid == session.get("nutzer_id"): + return jsonify({"error": "Eigenes Konto kann nicht gelöscht werden"}), 400 + grund = ((request.json or {}).get("begruendung") or "").strip() + if not grund: + return jsonify({"error": "Begründung erforderlich"}), 400 + db = get_db() + n = db.execute("SELECT benutzername FROM nutzer WHERE id=?", (uid,)).fetchone() + if not n: + return jsonify({"error": "Nicht gefunden"}), 404 + db.execute("UPDATE nutzer SET aktiv=0 WHERE id=?", (uid,)) + patienten = db.execute( + "SELECT id FROM patienten WHERE nutzer_id=? AND patient_status NOT IN ('archiviert','verstorben')", + (uid,) + ).fetchall() + hinweis = f"Konto gelöscht durch Admin – Grund: {grund}" + for p in patienten: + _archiviere_patient(db, p["id"], "archiviert", hinweis) + db.commit() + _log_admin_aktion("konto_geloescht", "nutzer", uid, n["benutzername"], + grund + f" ({len(patienten)} Patient(en) archiviert)") + return jsonify({"ok": True, "archivierte_patienten": len(patienten)}) + +@app.route("/api/admin/audit-log", methods=["GET"]) +@admin_required +def api_admin_audit_log(): + db = get_db() + rows = db.execute( + "SELECT * FROM admin_audit_log ORDER BY erstellt_am DESC LIMIT 200" + ).fetchall() + return jsonify([dict(r) for r in rows]) + +@app.route("/api/admin/patienten//dsgvo-export", methods=["POST"]) +@admin_required +def api_admin_dsgvo_export(pid): + grund = ((request.json or {}).get("begruendung") or "").strip() + if not grund: + return jsonify({"error": "Rechtsgrundlage / Aktenzeichen erforderlich"}), 400 + db = get_db() + p = db.execute("SELECT * FROM patienten WHERE id=?", (pid,)).fetchone() + if not p: + return jsonify({"error": "Nicht gefunden"}), 404 + u = _get_current_user() + + daten = { + "patient": dict(p), + "antraege": [dict(r) for r in db.execute("SELECT * FROM antraege WHERE patient_id=?", (pid,)).fetchall()], + "leistungen": [dict(r) for r in db.execute("SELECT * FROM leistungen WHERE patient_id=?", (pid,)).fetchall()], + "aufgaben": [dict(r) for r in db.execute("SELECT * FROM aufgaben WHERE patient_id=?", (pid,)).fetchall()], + "pflegetagebuch": [dict(r) for r in db.execute("SELECT * FROM pflegetagebuch WHERE patient_id=?", (pid,)).fetchall()], + "dokumente": [dict(r) for r in db.execute("SELECT * FROM dokumente WHERE patient_id=?", (pid,)).fetchall()], + "notizen_verlauf": [dict(r) for r in db.execute("SELECT * FROM notizen_verlauf WHERE patient_id=?", (pid,)).fetchall()], + "export_info": { + "exportiert_von": (u["name"] or u["benutzername"]) if u else None, + "exportiert_am": datetime.now().isoformat(), + "rechtsgrundlage": grund, + "hinweis": "Export gemäß Art. 15/20 DSGVO bzw. rechtlicher/anwaltlicher Anordnung. " + "Enthält alle in Pflegepilot gespeicherten Daten zu diesem Patienten inkl. Dokumenten." + } + } + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("daten.json", json.dumps(daten, indent=2, ensure_ascii=False, default=str)) + for ordner in ("briefe", "dokumente"): + src_dir = os.path.join(BASE_DIR, "static", ordner, str(pid)) + if os.path.isdir(src_dir): + for fn in os.listdir(src_dir): + full = os.path.join(src_dir, fn) + if os.path.isfile(full): + zf.write(full, arcname=f"{ordner}/{fn}") + foto_pfad = p["foto_pfad"] + if foto_pfad: + full = os.path.join(BASE_DIR, "static", "fotos", foto_pfad) + if os.path.isfile(full): + zf.write(full, arcname=f"fotos/{foto_pfad}") + buf.seek(0) + + _log_admin_aktion("dsgvo_export", "patient", pid, f"{p['vorname']} {p['nachname']}", grund) + + dateiname = f"DSGVO_Export_{p['nachname']}_{pid}_{date.today().isoformat()}.zip" + safe_dateiname = re.sub(r'[^\w\-.]', '_', dateiname) + return Response( + buf.getvalue(), + mimetype="application/zip", + headers={"Content-Disposition": f'attachment; filename="{safe_dateiname}"'} + ) + # ─── API: Admin / Backend-Einstellungen ────────────────────────────────────── @app.route("/api/admin/check", methods=["GET"]) def api_admin_check(): @@ -1098,19 +1283,7 @@ def api_patient_archivieren(pid): status = d.get("status", "archiviert") status_datum = d.get("status_datum", date.today().isoformat()) archiv_hinweis = d.get("archiv_hinweis", "") - # Aufbewahrungsfrist: 30 Jahre Verstorbene (§ 199 Abs. 2 BGB), sonst 10 Jahre (§ 630f BGB) - jahre = 30 if status == "verstorben" else 10 - try: - dt = datetime.strptime(status_datum, "%Y-%m-%d") - archiv_frist = dt.replace(year=dt.year + jahre).strftime("%Y-%m-%d") - except Exception: - archiv_frist = "" - db.execute(""" - UPDATE patienten - SET patient_status=?, status_datum=?, archiv_frist=?, archiv_hinweis=?, - geaendert_am=datetime('now','localtime') - WHERE id=? - """, (status, status_datum, archiv_frist, archiv_hinweis, pid)) + archiv_frist, jahre = _archiviere_patient(db, pid, status, archiv_hinweis, status_datum) db.commit() return jsonify({"ok": True, "archiv_frist": archiv_frist, "jahre": jahre}) diff --git a/pflegepilot_alpha_v2/pflegepilot/templates/index.html b/pflegepilot_alpha_v2/pflegepilot/templates/index.html index 7796d6d..be77e29 100644 --- a/pflegepilot_alpha_v2/pflegepilot/templates/index.html +++ b/pflegepilot_alpha_v2/pflegepilot/templates/index.html @@ -39,6 +39,11 @@ body{font-family:'IBM Plex Sans',sans-serif;background:var(--bg);color:var(--tex .sb-admin{border-top:1px solid var(--border);padding:4px 0 2px} .ni[data-p="backend"].on{background:var(--blbg);color:var(--bltx);border-left-color:var(--bltx)} .ni[data-p="backend"]:hover{background:var(--blbg);color:var(--bltx)} +.ni[data-p="admindash"].on{background:var(--rdbg);color:var(--rdtx);border-left-color:var(--rdtx)} +.ni[data-p="admindash"]:hover{background:var(--rdbg);color:var(--rdtx)} +.audit-row{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid var(--border);font-size:13px} +.audit-row:last-child{border-bottom:none} +.audit-aktion{font-weight:500;flex-shrink:0;min-width:130px} .admin-pill{font-size:10px;padding:1px 7px;border-radius:9px;background:var(--ambg);color:var(--amtx);margin-left:auto;font-weight:500} .api-token-wrap{position:relative} .api-token-wrap input{padding-right:38px} @@ -276,6 +281,7 @@ textarea{resize:vertical;min-height:80px}
BackendAdmin
+
Admin-DashboardAdmin