""" ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES), mandantenfaehig. Betrieb gemaess ISO 27001 / DSGVO / NIS 2 (Nutzer-Vorgabe, 2026-07-13): - Persistenz in PostgreSQL statt Dateien (Zugriffskontrolle, Backup/Recovery, Verschluesselung im Ruhezustand ueber die DB moeglich). - Audit-Log fuer alle sicherheitsrelevanten/aendernden Aktionen (ISO 27001 A.12.4, DSGVO Art. 30 Rechenschaftspflicht). - Ersteinrichtungs-Assistent (/setup/new): legt pro Kunde einen eigenen Mandanten (Tenant) mit eigenem Admin-Konto, SLA-Standardwerten und DSGVO-Angaben (Datenschutzbeauftragter, Aufbewahrungsfristen) an. Mandantenfaehig: mehrere Organisationen auf einer Instanz, Daten strikt per tenant_id getrennt (kein Datenzugriff ueber Mandantengrenzen). - Automatische Aufbewahrungsfrist-Bereinigung (Hintergrund-Thread, siehe db.run_retention_cleanup) -- loescht abgeschlossene Tickets/Audit-Log- Eintraege, die aelter als die hinterlegte Frist sind (DSGVO Speicher- begrenzung). AES bleibt ein buchbarer Service im Katalog, integriert per HTTP (kein Code- Import) -- siehe _aes_live_status(). ENV: DATABASE_URL postgresql://user:pass@host:5432/dbname ITSM_SECRET_KEY Flask-Session-Secret AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration RETENTION_INTERVAL_SECONDS Intervall fuer die Retention-Bereinigung (Default 24h) """ import os import html import time import secrets import threading import datetime as _dt import urllib.request import urllib.error from flask import Flask, request, redirect, url_for, session, jsonify, abort from werkzeug.security import generate_password_hash, check_password_hash import db AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/") RETENTION_INTERVAL_SECONDS = int(os.getenv("RETENTION_INTERVAL_SECONDS", str(24 * 60 * 60))) app = Flask(__name__) app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32) CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"] PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"] def _client_ip(): return request.headers.get("X-Forwarded-For", request.remote_addr or "") def _now(): return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" def _jsonable(v): if isinstance(v, (_dt.datetime, _dt.date)): return v.isoformat() if isinstance(v, dict): return {k: _jsonable(x) for k, x in v.items()} if isinstance(v, list): return [_jsonable(x) for x in v] return v # ── Auth / Session-Helfer ────────────────────────────────────────────────────── def _current_user(): if not session.get("user_id"): return None return { "id": session["user_id"], "email": session.get("email"), "tenant_id": session.get("tenant_id"), "tenant_name": session.get("tenant_name"), "role": session.get("role"), } def _require_login(): if not session.get("user_id"): return redirect(url_for("login", next=request.path)) return None def _require_admin(): r = _require_login() if r: return r if session.get("role") != "admin": return ("

Zugriff verweigert -- nur fuer Administratoren.

", 403) return None # ── CSS (an AES-Farbschema angelehnt) ────────────────────────────────────────── _CSS = """ :root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5; --sub:#93a3b8;--accent:#4c9eba;--accent2:#5db3d0;--ok:#3ecf8e;--warn:#e0a83e; --bad:#e5534b;--crit:#c9364a;} *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px} a{color:var(--accent2);text-decoration:none} a:hover{text-decoration:underline} .login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center} .login-box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:28px;width:340px} .login-box.wide{width:560px} .login-box h1{margin:0 0 4px;font-size:22px} .login-box input,.login-box select,.login-box textarea{width:100%;padding:9px 10px;margin-bottom:10px;background:var(--bg); border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px} .btn{background:var(--accent);border:none;color:#0c1420;font-weight:600;padding:9px 14px; border-radius:6px;cursor:pointer;font-size:13px} .btn:hover{background:var(--accent2)} .btn.ghost{background:transparent;border:1px solid var(--border);color:var(--text)} .sz{color:var(--sub);font-size:12px} .shell{display:flex;min-height:100vh} .sidebar{width:220px;flex:0 0 220px;background:var(--panel);border-right:1px solid var(--border);padding:16px 0;overflow-y:auto} .brand{padding:0 16px 16px;font-weight:700;font-size:16px} .brand .sz{font-weight:400} .navgroup{margin-top:14px} .navgroup h4{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub); padding:0 16px;margin:0 0 4px} .navgroup a{display:block;padding:6px 16px;color:var(--text);font-size:13px} .navgroup a:hover{background:var(--panel2);text-decoration:none} .navgroup a.active{background:var(--panel2);border-left:2px solid var(--accent);color:var(--accent2)} .main{flex:1;min-width:0} .topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 24px; border-bottom:1px solid var(--border);background:var(--panel)} .topbar .search{flex:1;max-width:420px;background:var(--bg);border:1px solid var(--border); border-radius:6px;padding:7px 12px;color:var(--sub);font-size:13px} .content{padding:24px} h1.page-title{font-size:22px;margin:0 0 18px} h2.section-title{font-size:15px;margin:22px 0 10px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em} .kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px} .kpi{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px} .kpi .lbl{color:var(--sub);font-size:12px;margin-bottom:6px} .kpi .val{font-size:26px;font-weight:700} .tabs{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap} .tabs a{padding:6px 12px;border-radius:6px;font-size:13px;color:var(--sub);border:1px solid transparent} .tabs a.active{background:var(--panel2);color:var(--text);border-color:var(--border)} table.tickets,table.audit{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden} table.tickets th,table.audit th{text-align:left;font-size:11px;text-transform:uppercase;color:var(--sub); padding:10px 12px;border-bottom:1px solid var(--border);background:var(--panel2)} table.tickets td,table.audit td{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top;font-size:13px} table.tickets tr:last-child td,table.audit tr:last-child td{border-bottom:none} table.tickets tr.row{cursor:pointer} table.tickets tr.row:hover{background:var(--panel2)} .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600} .pill.offen{background:#2e3a52;color:#9fb3ce} .pill.bearbeitung,.pill.inbearbeitung{background:#4a3a1a;color:var(--warn)} .pill.warten{background:#3a2f52;color:#b79ee0} .pill.geloest{background:#1a4a35;color:var(--ok)} .pill.geschlossen{background:#2a2a2a;color:#888} .pill.ueberfaellig{background:#4a1e22;color:var(--bad)} .pill.krit{color:var(--crit)} .pill.hoch{color:var(--bad)} .pill.mittel{color:var(--warn)} .pill.niedrig{color:var(--sub)} .pill.admin{background:#1a4a35;color:var(--ok)} .pill.agent{background:#2e3a52;color:#9fb3ce} .bar-track{background:#2a3446;border-radius:99px;height:6px;width:90px;display:inline-block;vertical-align:middle} .bar-fill{background:var(--accent);height:6px;border-radius:99px} .panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:16px} .card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px} .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px} .svc-card{display:flex;justify-content:space-between;align-items:flex-start} .svc-card .badge{padding:2px 9px;border-radius:99px;font-size:11px;background:#1a4a35;color:var(--ok)} .svc-card .badge.err{background:#4a1e22;color:var(--bad)} .svc-card .badge.unknown{background:#2a2a2a;color:#888} input,textarea,select{ background:var(--bg);border:1px solid var(--border);border-radius:6px; color:var(--text);padding:8px 10px;font-size:13px;width:100%} textarea{min-height:70px;font-family:inherit} details > summary{list-style:none;cursor:pointer} details > summary::-webkit-details-marker{display:none} .formrow{margin-bottom:10px} .formrow label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px} #detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;z-index:40} #detail-panel{position:fixed;right:0;top:0;bottom:0;width:420px;background:var(--panel); border-left:1px solid var(--border);z-index:41;transform:translateX(100%); transition:transform .18s ease;overflow-y:auto;padding:20px} #detail-panel.open{transform:translateX(0)} #detail-overlay.open{display:block} .dt-tabs{display:flex;gap:14px;border-bottom:1px solid var(--border);margin:14px 0} .dt-tabs span{padding-bottom:8px;color:var(--sub);font-size:13px;cursor:pointer} .dt-tabs span.active{color:var(--accent2);border-bottom:2px solid var(--accent2)} .tl-item{border-left:2px solid var(--border);padding-left:12px;margin-bottom:12px;position:relative} .tl-item::before{content:'';position:absolute;left:-5px;top:2px;width:8px;height:8px;border-radius:50%;background:var(--accent)} .err{color:#E5534B;font-size:13px;margin:0 0 10px} .hint{color:var(--sub);font-size:11px;margin:-6px 0 10px} """ _CSS_TAG = "" _NAV = [ ("Service Management", [ ("/tickets", "Tickets"), ("/probleme", "Probleme"), ("/aenderungen", "Aenderungen"), ("/releases", "Releases"), ]), ("Services", [("/services", "Service-Katalog")]), ("Wissen", [("/wissen", "Wissensdatenbank")]), ("Assets", [("/assets", "Asset-Liste")]), ("Administration", [ ("/admin", "Einstellungen"), ("/admin/users", "Benutzer"), ("/admin/audit", "Audit-Log"), ]), ] CI_TYPES = ["Server", "Software", "Lizenz", "Vertrag", "Netzwerkgeraet", "Sonstiges"] CI_STATUS = ["Aktiv", "Inaktiv", "Wartung", "Ausgemustert"] REL_TYPES = ["haengt ab von", "beinhaltet", "verbunden mit", "ersetzt"] KB_KATEGORIEN = ["Allgemein", "Anleitung", "Stoerung", "Konfiguration", "FAQ"] def _attrs_to_text(attrs): """dict -> 'Schluessel: Wert' je Zeile, fuer die Textarea im Formular.""" if not attrs: return "" return "\n".join("%s: %s" % (k, v) for k, v in attrs.items()) def _text_to_attrs(text): """'Schluessel: Wert' je Zeile -> dict. Leere/fehlerhafte Zeilen werden ignoriert.""" out = {} for line in (text or "").splitlines(): line = line.strip() if not line or ":" not in line: continue k, v = line.split(":", 1) k = k.strip() if k: out[k] = v.strip() return out def _shell(active_path, title, body_html, extra_head=""): u = _current_user() groups_html = [] for group_name, items in _NAV: links = "".join( '%s' % (href, "active" if href == active_path else "", html.escape(label)) for href, label in items ) groups_html.append('' % (html.escape(group_name), links)) nav_html = "".join(groups_html) who = "%s · %s" % (html.escape(u["email"] or ""), html.escape(u["tenant_name"] or "")) if u else "" return """ %s -- ITSM%s%s
%s · Abmelden
%s
""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, who, body_html) # ── Ersteinrichtung (neuer Mandant) ──────────────────────────────────────────── @app.route("/setup/new", methods=["GET", "POST"]) def setup_new(): # Die Ersteinrichtung ist ein einmaliger Vorgang: sobald irgendein # Mandant existiert, ist die Registrierung neuer Organisationen ueber # diese Route dauerhaft gesperrt (Nutzer-Vorgabe 2026-07-13: "/setup/new # darf nur einmal funktionieren"). Weitere Mandanten koennen danach nur # noch durch einen Administrator ueber die Datenbank/ein internes # Verfahren angelegt werden, nicht mehr per offenem Self-Service-Formular. if db.any_tenant_exists(): return redirect(url_for("login", already_setup="1")) error = None # Vorbelegung: bei GET die Standardwerte, bei einem fehlgeschlagenen POST # die vom Nutzer eingegebenen Werte (ausser Passwoerter -- die bleiben aus # Sicherheitsgruenden leer und muessen erneut eingegeben werden). firma = "" email = "" dsb_name = "" dsb_email = "" retention_tickets = "1095" retention_audit = "1825" sla_antwort = "480" sla_loesung = "2880" if request.method == "POST": firma = request.form.get("firma", "").strip() email = request.form.get("email", "").strip().lower() pw = request.form.get("password", "") pw2 = request.form.get("password2", "") dsb_name = request.form.get("dsb_name", "").strip() dsb_email = request.form.get("dsb_email", "").strip() retention_tickets = request.form.get("retention_tickets_days", "1095").strip() retention_audit = request.form.get("retention_audit_days", "1825").strip() sla_antwort = request.form.get("sla_antwort_minuten", "480").strip() sla_loesung = request.form.get("sla_loesung_minuten", "2880").strip() if not firma: error = "Firmenname ist erforderlich." elif not email or "@" not in email: error = "Bitte eine gueltige E-Mail-Adresse angeben." elif len(pw) < 10: error = "Das Passwort muss mindestens 10 Zeichen lang sein." elif pw != pw2: error = "Die Passwoerter stimmen nicht ueberein." elif db.email_exists(email): error = "Diese E-Mail-Adresse ist bereits registriert." else: try: tenant_id = db.create_tenant( firma, dsb_name or None, dsb_email or None, int(retention_tickets or 1095), int(retention_audit or 1825), int(sla_antwort or 480), int(sla_loesung or 2880), ) user_id = db.create_user(tenant_id, email, generate_password_hash(pw), role="admin") db.create_service( tenant_id, "AES -- Autonomous Engineering System", "Automatisierte Software-Entwicklung: Projekte, Phasen, " "LLM-gestuetzte Agenten, Auto-Fix und Release-Pipeline.", "Entwicklung", True, AES_DASHBOARD_URL, ) db.log_audit(tenant_id, user_id, "tenant_created", "tenant", str(tenant_id), {"firma": firma}, _client_ip()) db.log_audit(tenant_id, user_id, "user_created", "user", str(user_id), {"email": email, "role": "admin"}, _client_ip()) return redirect(url_for("login", registered="1")) except Exception as e: error = "Fehler bei der Einrichtung: %s" % html.escape(str(e)) err_html = "

%s

" % error if error else "" return """ Neue Organisation einrichten -- ITSM%s

Neue Organisation einrichten

Jede Organisation erhaelt einen eigenen, getrennten Mandanten. Diese Angaben lassen sich unter Einstellungen jederzeit anpassen.
%s

Firmenprofil

Administrator-Konto

Mindestens 10 Zeichen.

SLA-Standardwerte

Datenschutz (DSGVO)

Diese Werte legen fest, wie lange Tickets bzw. Audit-Eintraege vorgehalten werden (DSGVO Speicherbegrenzung). Ein Hintergrund-Job bereinigt automatisch alle 24 Stunden.
Bereits registriert? Anmelden
""" % ( _CSS_TAG, err_html, html.escape(firma), html.escape(email), html.escape(sla_antwort), html.escape(sla_loesung), html.escape(dsb_name), html.escape(dsb_email), html.escape(retention_tickets), html.escape(retention_audit), ) # ── Login/Logout ──────────────────────────────────────────────────────────────── @app.route("/login", methods=["GET", "POST"]) def login(): error = None notice = None if request.args.get("registered"): notice = "Organisation angelegt. Bitte melde dich mit deinem Admin-Konto an." elif request.args.get("already_setup"): notice = "Die Ersteinrichtung wurde bereits abgeschlossen. Bitte melde dich mit deinem bestehenden Konto an." if request.method == "POST": email = request.form.get("email", "").strip().lower() pw = request.form.get("password", "") user = db.get_user_by_email(email) if user and check_password_hash(user["password_hash"], pw): tenant = db.get_tenant(user["tenant_id"]) session["user_id"] = user["id"] session["email"] = user["email"] session["tenant_id"] = user["tenant_id"] session["tenant_name"] = tenant["name"] if tenant else "" session["role"] = user["role"] db.touch_last_login(user["id"]) db.log_audit(user["tenant_id"], user["id"], "login_success", "user", str(user["id"]), None, _client_ip()) return redirect(request.args.get("next") or url_for("tickets_route")) db.log_audit(user["tenant_id"] if user else None, user["id"] if user else None, "login_failed", "user", email, None, _client_ip()) error = "E-Mail oder Passwort falsch." err_html = "

%s

" % html.escape(error) if error else "" notice_html = "

%s

" % html.escape(notice) if notice else "" # Der Registrierungs-Link wird nur angezeigt, solange die Ersteinrichtung # noch nicht abgeschlossen ist -- danach ist /setup/new gesperrt, ein # Link dorthin waere ein toter Verweis (Nutzer-Vorgabe 2026-07-13). setup_link_html = ('
Neue Organisation? ' 'Jetzt einrichten
') if not db.any_tenant_exists() else "" return """ Anmelden -- ITSM%s

ITSM

IT Service Management
%s%s %s
""" % (_CSS_TAG, err_html, notice_html, setup_link_html) @app.route("/logout") def logout(): u = _current_user() if u: db.log_audit(u["tenant_id"], u["id"], "logout", "user", str(u["id"]), None, _client_ip()) session.clear() return redirect(url_for("login")) # ── Tickets: Berechnung + Rendering ──────────────────────────────────────────── def _is_overdue(t): if t["status"] in ("Geloest", "Geschlossen"): return False created = t["created_at"] if created.tzinfo is None: created = created.replace(tzinfo=_dt.timezone.utc) age_min = (_dt.datetime.now(_dt.timezone.utc) - created).total_seconds() / 60 return age_min > (t.get("sla_loesung_minuten") or 999999) def _kpis(tickets): total = len(tickets) offen = sum(1 for t in tickets if t["status"] == "Offen") bearbeitung = sum(1 for t in tickets if t["status"] == "In Bearbeitung") warten = sum(1 for t in tickets if t["status"] == "Warten") ueberfaellig = sum(1 for t in tickets if _is_overdue(t)) now = _dt.datetime.now(_dt.timezone.utc) geloest_7d = 0 for t in tickets: if t["status"] in ("Geloest", "Geschlossen"): upd = t["updated_at"] if upd.tzinfo is None: upd = upd.replace(tzinfo=_dt.timezone.utc) if (now - upd).days <= 7: geloest_7d += 1 return {"Gesamt": total, "Offen": offen, "In Bearbeitung": bearbeitung, "Warten auf Input": warten, "Ueberfaellig": ueberfaellig, "Geloest (7 Tage)": geloest_7d} def _status_class(s): return {"Offen": "offen", "In Bearbeitung": "inbearbeitung", "Warten": "warten", "Geloest": "geloest", "Geschlossen": "geschlossen"}.get(s, "offen") def _prio_class(p): return {"Kritisch": "krit", "Hoch": "hoch", "Mittel": "mittel", "Niedrig": "niedrig"}.get(p, "mittel") def _ticket_row(t): status = "ueberfaellig" if _is_overdue(t) else _status_class(t["status"]) status_label = "Ueberfaellig" if _is_overdue(t) else t["status"] return """ %s %s
%s
%s %s %s %s %s
%s%% """ % ( t["id"], t["id"], html.escape(t["ticket_nr"]), html.escape(t["titel"]), html.escape(t.get("service_name") or ""), status, status_label, _prio_class(t["prioritaet"]), t["prioritaet"], t["kategorie"], html.escape(t["zugewiesen_an"] or ""), t["updated_at"].strftime("%Y-%m-%d %H:%M"), t["fortschritt"], t["fortschritt"]) def _kpi_tile(label, value): return '
%s
%s
' % (html.escape(label), value) def _tickets_body(tenant_id, filter_status=None, filter_kategorie=None): tickets = db.list_tickets(tenant_id) kpis = _kpis(tickets) kpi_html = "".join(_kpi_tile(k, v) for k, v in kpis.items()) shown = tickets if filter_kategorie: shown = [t for t in shown if t["kategorie"] == filter_kategorie] tabs = [("", "Alle"), ("Offen", "Offen"), ("In Bearbeitung", "In Bearbeitung"), ("Warten", "Warten"), ("Geloest", "Geloest"), ("Ueberfaellig", "Ueberfaellig")] base = "/probleme" if filter_kategorie == "Problem" else ( "/aenderungen" if filter_kategorie == "Change" else ( "/releases" if filter_kategorie == "Release" else "/tickets")) tabs_html = "".join( '%s' % ( base, ("?status=" + s) if s else "", "active" if (s or "") == (filter_status or "") else "", label) for s, label in tabs) if filter_status == "Ueberfaellig": shown = [t for t in shown if _is_overdue(t)] elif filter_status: shown = [t for t in shown if t["status"] == filter_status] rows = "".join(_ticket_row(t) for t in shown) or 'Keine Tickets.' services = db.list_services(tenant_id) svc_opts = "".join('' % (s["id"], html.escape(s["name"])) for s in services) new_form = """

Neues Ticket

""" % ( "".join('' % (c, c) for c in CATEGORIES), "".join('' % (p, p) for p in PRIORITIES), svc_opts) return """

Tickets

%s
%s
%s
%s
Ticket-NrTitel (Service)StatusPrioritaet KategorieZugewiesen anAktualisiertFortschritt
""" % (kpi_html, new_form, tabs_html, rows) @app.route("/tickets") def tickets_route(): r = _require_login() if r: return r return _shell("/tickets", "Tickets", _tickets_body(session["tenant_id"], request.args.get("status"))) @app.route("/probleme") def probleme_route(): r = _require_login() if r: return r return _shell("/probleme", "Probleme", _tickets_body(session["tenant_id"], request.args.get("status"), "Problem")) @app.route("/aenderungen") def aenderungen_route(): r = _require_login() if r: return r return _shell("/aenderungen", "Aenderungen", _tickets_body(session["tenant_id"], request.args.get("status"), "Change")) @app.route("/releases") def releases_route(): r = _require_login() if r: return r return _shell("/releases", "Releases", _tickets_body(session["tenant_id"], request.args.get("status"), "Release")) @app.route("/tickets/new", methods=["POST"]) def tickets_new_route(): r = _require_login() if r: return r tenant_id = session["tenant_id"] service_id = request.form.get("service_id") or None tid, ticket_nr = db.create_ticket( tenant_id, request.form.get("titel", "").strip() or "(ohne Titel)", request.form.get("beschreibung", "").strip(), int(service_id) if service_id else None, request.form.get("prioritaet", "Mittel"), request.form.get("kategorie", "Task"), session.get("email"), session["user_id"], 480, 2880, session.get("email"), ) db.log_audit(tenant_id, session["user_id"], "ticket_created", "ticket", ticket_nr, None, _client_ip()) return redirect(url_for("tickets_route")) @app.route("/tickets//status", methods=["POST"]) def ticket_status_route(ticket_id): r = _require_login() if r: return r tenant_id = session["tenant_id"] status = request.form.get("status", "Offen") db.update_ticket_status(tenant_id, ticket_id, status, session.get("email")) db.log_audit(tenant_id, session["user_id"], "ticket_status_changed", "ticket", str(ticket_id), {"status": status}, _client_ip()) return jsonify({"ok": True}) @app.route("/api/tickets/") def api_ticket(ticket_id): r = _require_login() if r: return r ticket = db.get_ticket(session["tenant_id"], ticket_id) if not ticket: abort(404) return jsonify(_jsonable(ticket)) # ── Service-Katalog ───────────────────────────────────────────────────────────── def _service_live_status(endpoint): """Prueft die Live-Erreichbarkeit eines gebuchten Service-Endpunkts (z.B. AES-Dashboard). Generisch fuer alle Services mit Endpoint, nicht nur AES. Timeout kurz gehalten, damit ein haengender/ausgefallener Service den Katalog nicht blockiert. Rueckgabe: True (erreichbar) / False (nicht erreichbar) / None (kein Endpoint hinterlegt).""" if not endpoint: return None try: req = urllib.request.Request(endpoint, method="GET") with urllib.request.urlopen(req, timeout=4) as resp: return 200 <= resp.status < 400 except Exception: return False def _services_body(tenant_id): services = db.list_services(tenant_id) cards = [] for s in services: if not s["gebucht"]: badge = 'nicht gebucht' elif s.get("endpoint"): live = _service_live_status(s["endpoint"]) if live is True: badge = 'gebucht · erreichbar' elif live is False: badge = 'gebucht · nicht erreichbar' else: badge = 'gebucht' else: badge = 'gebucht' link = ('' % html.escape(s["endpoint"])) if s.get("endpoint") else "" cards.append("""
%s
%s
%s
%s
""" % ( html.escape(s["name"]), html.escape(s["beschreibung"] or ""), badge, link)) add_form = """

Service hinzufuegen

""" return '

Service-Katalog

' + "".join(cards) + add_form @app.route("/services") def services_route(): r = _require_login() if r: return r return _shell("/services", "Service-Katalog", _services_body(session["tenant_id"])) @app.route("/services/new", methods=["POST"]) def services_new_route(): r = _require_login() if r: return r tenant_id = session["tenant_id"] sid = db.create_service( tenant_id, request.form.get("name", "").strip(), request.form.get("beschreibung", "").strip(), "Sonstiges", True, request.form.get("endpoint", "").strip(), ) db.log_audit(tenant_id, session["user_id"], "service_created", "service", str(sid), None, _client_ip()) return redirect(url_for("services_route")) # ── Administration: Einstellungen + Audit-Log ────────────────────────────────── @app.route("/admin", methods=["GET", "POST"]) def admin_route(): r = _require_admin() if r: return r tenant_id = session["tenant_id"] notice = "" if request.method == "POST": db.update_tenant_settings( tenant_id, request.form.get("dsb_name", "").strip() or None, request.form.get("dsb_email", "").strip() or None, int(request.form.get("retention_tickets_days") or 1095), int(request.form.get("retention_audit_days") or 1825), int(request.form.get("sla_antwort_minuten") or 480), int(request.form.get("sla_loesung_minuten") or 2880), ) db.log_audit(tenant_id, session["user_id"], "tenant_settings_updated", "tenant", str(tenant_id), None, _client_ip()) notice = "

Gespeichert.

" if request.args.get("cleanup"): notice += "

Bereinigung durchgefuehrt -- Details im Audit-Log.

" t = db.get_tenant(tenant_id) body = """

Einstellungen

%s

%s

SLA-Standardwerte

Datenschutz (DSGVO)

Aufbewahrungsfrist-Bereinigung

Laeuft automatisch alle 24 Stunden im Hintergrund: geloeste/geschlossene Tickets und Audit-Log-Eintraege, die aelter als die oben hinterlegten Fristen sind, werden entfernt. Offene/laufende Tickets werden nie geloescht.

""" % ( html.escape(t["name"]), notice, t["sla_antwort_minuten"], t["sla_loesung_minuten"], html.escape(t["dsb_name"] or ""), html.escape(t["dsb_email"] or ""), t["retention_tickets_days"], t["retention_audit_days"]) return _shell("/admin", "Einstellungen", body) @app.route("/admin/retention/run", methods=["POST"]) def admin_retention_run(): r = _require_admin() if r: return r db.run_retention_cleanup() return redirect(url_for("admin_route", cleanup="1")) @app.route("/admin/audit") def admin_audit_route(): r = _require_admin() if r: return r entries = db.list_audit(session["tenant_id"], limit=300) rows = "".join( "%s%s%s%s %s" % ( e["zeit"].strftime("%Y-%m-%d %H:%M:%S"), html.escape(e["user_email"] or "(unbekannt)"), html.escape(e["aktion"]), html.escape(e["entity_typ"] or ""), html.escape(e["entity_id"] or "")) for e in entries ) or "Keine Eintraege." body = """

Audit-Log

Alle sicherheitsrelevanten Aktionen dieses Mandanten (Anmeldungen, Aenderungen). Aufbewahrung gemaess Einstellungen.
%s
Zeitpunkt (UTC)BenutzerAktionObjekt
""" % rows return _shell("/admin/audit", "Audit-Log", body) # ── Administration: Benutzerverwaltung ───────────────────────────────────────── def _role_pill(role): return '%s' % (html.escape(role), html.escape(role)) def _users_body(tenant_id, notice="", form_vals=None): # form_vals: bei einem fehlgeschlagenen POST die eingegebenen Werte (ausser # Passwoerter), damit das Formular nicht komplett geleert wird (Nutzer- # Vorgabe 2026-07-13, analog zum gleichen Fix bei /setup/new). fv = form_vals or {} users = db.list_users(tenant_id) rows = [] for u in users: status_pill = ('aktiv' if u["active"] else 'gesperrt') toggle_label = "Sperren" if u["active"] else "Entsperren" role_opts = "".join( '' % (r, "selected" if r == u["role"] else "", r) for r in ("admin", "agent") ) name = " ".join(x for x in (u["vorname"], u["nachname"]) if x) or "--" rows.append(""" %s %s %s %s %s %s %s Details
""" % ( html.escape(u["email"]), html.escape(name), _role_pill(u["role"]), status_pill, html.escape(u["telefon"] or ""), html.escape(u["auth_source"]), u["last_login_at"].strftime("%Y-%m-%d %H:%M") if u["last_login_at"] else "nie", u["id"], u["id"], role_opts, u["id"], toggle_label, )) table = """%s
E-MailNameRolleStatusTelefonQuelleLetzte AnmeldungAktionen
""" % ("".join(rows) or "Keine Benutzer.") open_attr = " open" if (notice or fv) else "" add_form = """ + Neuen Benutzer

Neuen Benutzer anlegen

Manuelle Verwaltung. Eine AD/LDAP-Anbindung ist als naechste Ausbaustufe vorgesehen (Spalte "Quelle" zeigt dann "ldap" statt "local").
Mindestens 10 Zeichen. Der Benutzer sollte das Passwort nach der ersten Anmeldung selbst aendern.

Details (optional)

""" % ( open_attr, html.escape(fv.get("email", "")), "selected" if fv.get("role") == "agent" or not fv.get("role") else "", "selected" if fv.get("role") == "admin" else "", html.escape(fv.get("vorname", "")), html.escape(fv.get("nachname", "")), html.escape(fv.get("telefon", "")), html.escape(fv.get("abteilung", "")), html.escape(fv.get("adresse", "")), ) return '

Benutzerverwaltung

' + notice + table + add_form @app.route("/admin/users", methods=["GET", "POST"]) def admin_users_route(): r = _require_admin() if r: return r tenant_id = session["tenant_id"] notice = "" form_vals = {} if request.method == "POST": email = request.form.get("email", "").strip().lower() role = request.form.get("role", "agent").strip() if role not in ("admin", "agent"): role = "agent" pw = request.form.get("password", "") pw2 = request.form.get("password2", "") vorname = request.form.get("vorname", "").strip() nachname = request.form.get("nachname", "").strip() telefon = request.form.get("telefon", "").strip() abteilung = request.form.get("abteilung", "").strip() adresse = request.form.get("adresse", "").strip() form_vals = {"email": email, "role": role, "vorname": vorname, "nachname": nachname, "telefon": telefon, "abteilung": abteilung, "adresse": adresse} if not email or "@" not in email: notice = "

Bitte eine gueltige E-Mail-Adresse angeben.

" elif len(pw) < 10: notice = "

Das Passwort muss mindestens 10 Zeichen lang sein.

" elif pw != pw2: notice = "

Die Passwoerter stimmen nicht ueberein.

" elif db.email_exists(email): notice = "

Diese E-Mail-Adresse ist bereits registriert.

" else: uid = db.create_user(tenant_id, email, generate_password_hash(pw), role=role, vorname=vorname or None, nachname=nachname or None, telefon=telefon or None, abteilung=abteilung or None, adresse=adresse or None) db.log_audit(tenant_id, session["user_id"], "user_created", "user", str(uid), {"email": email, "role": role, "auth_source": "local"}, _client_ip()) notice = "

Benutzer angelegt.

" form_vals = {} return _shell("/admin/users", "Benutzerverwaltung", _users_body(tenant_id, notice, form_vals)) @app.route("/admin/users//edit", methods=["GET", "POST"]) def admin_users_edit_route(user_id): r = _require_admin() if r: return r tenant_id = session["tenant_id"] u = db.get_user(tenant_id, user_id) if not u: abort(404) error = None ok_msg = None if request.method == "POST": email = request.form.get("email", "").strip().lower() vorname = request.form.get("vorname", "").strip() nachname = request.form.get("nachname", "").strip() telefon = request.form.get("telefon", "").strip() abteilung = request.form.get("abteilung", "").strip() adresse = request.form.get("adresse", "").strip() if not email or "@" not in email: error = "Bitte eine gueltige E-Mail-Adresse angeben." elif db.email_exists_excluding(email, user_id): error = "Diese E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet." else: old_email = u["email"] if email != old_email: db.update_user_email(tenant_id, user_id, email) db.log_audit(tenant_id, session["user_id"], "user_email_changed", "user", str(user_id), {"alt": old_email, "neu": email}, _client_ip()) if user_id == session["user_id"]: session["email"] = email db.update_user_profile(tenant_id, user_id, vorname or None, nachname or None, telefon or None, abteilung or None, adresse or None) db.log_audit(tenant_id, session["user_id"], "user_profile_updated", "user", str(user_id), None, _client_ip()) u = dict(u) u.update({"email": email, "vorname": vorname, "nachname": nachname, "telefon": telefon, "abteilung": abteilung, "adresse": adresse}) ok_msg = "Gespeichert." err_html = "

%s

" % html.escape(error) if error else "" ok_html = ("

%s

" % html.escape(ok_msg)) if ok_msg else "" body = """

Benutzerdetails

%s%s
Wird sofort als Login-Adresse wirksam. Aenderungen werden im Audit-Log erfasst.

Details (optional)

Zurueck zur Benutzerverwaltung""" % ( err_html, ok_html, html.escape(u["email"]), html.escape(u["vorname"] or ""), html.escape(u["nachname"] or ""), html.escape(u["telefon"] or ""), html.escape(u["abteilung"] or ""), html.escape(u["adresse"] or ""), ) return _shell("/admin/users", "Benutzerdetails", body) @app.route("/admin/users//role", methods=["POST"]) def admin_users_role_route(user_id): r = _require_admin() if r: return r tenant_id = session["tenant_id"] role = request.form.get("role", "agent").strip() if role not in ("admin", "agent"): return redirect(url_for("admin_users_route")) db.set_user_role(tenant_id, user_id, role) db.log_audit(tenant_id, session["user_id"], "user_role_changed", "user", str(user_id), {"role": role}, _client_ip()) return redirect(url_for("admin_users_route")) @app.route("/admin/users//active", methods=["POST"]) def admin_users_active_route(user_id): r = _require_admin() if r: return r tenant_id = session["tenant_id"] current = db.get_user(tenant_id, user_id) if not current: return redirect(url_for("admin_users_route")) new_active = not current["active"] if user_id == session["user_id"] and not new_active: return redirect(url_for("admin_users_route")) # eigenes Konto nicht selbst sperren db.set_user_active(tenant_id, user_id, new_active) db.log_audit(tenant_id, session["user_id"], "user_activated" if new_active else "user_deactivated", "user", str(user_id), None, _client_ip()) return redirect(url_for("admin_users_route")) # ── Wissensdatenbank ──────────────────────────────────────────────────────────── def _kb_list_body(tenant_id): kategorie = request.args.get("kategorie") or None q = request.args.get("q") or None articles = db.list_kb_articles(tenant_id, kategorie=kategorie, q=q) tabs = "".join( '%s' % ( html.escape(k), "active" if kategorie == k else "", html.escape(k)) for k in KB_KATEGORIEN ) tabs = 'Alle' % ("active" if not kategorie else "") + tabs rows = [] for a in articles: tag_pills = "".join('%s' % html.escape(t.strip()) for t in (a["tags"] or "").split(",") if t.strip()) rows.append(""" %s%s%s %s%s""" % ( a["id"], html.escape(a["titel"]), html.escape(a["kategorie"]), tag_pills, html.escape(a["autor_email"] or ""), a["updated_at"].strftime("%Y-%m-%d %H:%M"))) table = """%s
TitelKategorieTagsAutorAktualisiert
""" % ("".join(rows) or "Keine Artikel.") search_form = """
""" % html.escape(q or "") return ('

Wissensdatenbank

' '
%s
%s%s' '' % (tabs, search_form, table)) @app.route("/wissen") def wissen_route(): r = _require_login() if r: return r return _shell("/wissen", "Wissensdatenbank", _kb_list_body(session["tenant_id"])) @app.route("/wissen/new", methods=["GET", "POST"]) def wissen_new_route(): r = _require_login() if r: return r tenant_id = session["tenant_id"] error = None titel, kategorie, inhalt, tags = "", "Allgemein", "", "" if request.method == "POST": titel = request.form.get("titel", "").strip() kategorie = request.form.get("kategorie", "Allgemein").strip() inhalt = request.form.get("inhalt", "").strip() tags = request.form.get("tags", "").strip() if not titel: error = "Titel ist erforderlich." elif not inhalt: error = "Inhalt darf nicht leer sein." else: aid = db.create_kb_article(tenant_id, titel, kategorie, inhalt, tags, session["user_id"]) db.log_audit(tenant_id, session["user_id"], "kb_article_created", "kb_article", str(aid), {"titel": titel}, _client_ip()) return redirect(url_for("wissen_detail_route", article_id=aid)) kat_opts = "".join('' % (k, "selected" if k == kategorie else "", k) for k in KB_KATEGORIEN) err_html = "

%s

" % html.escape(error) if error else "" body = """

Neuer Wissensartikel

%s
""" % (err_html, html.escape(titel), kat_opts, html.escape(inhalt), html.escape(tags)) return _shell("/wissen", "Neuer Wissensartikel", body) @app.route("/wissen/") def wissen_detail_route(article_id): r = _require_login() if r: return r a = db.get_kb_article(session["tenant_id"], article_id) if not a: abort(404) tag_pills = "".join('%s' % html.escape(t.strip()) for t in (a["tags"] or "").split(",") if t.strip()) body = """

%s

%s · %s · aktualisiert %s
%s
%s
Bearbeiten Zurueck zur Uebersicht""" % ( html.escape(a["titel"]), html.escape(a["kategorie"]), html.escape(a["autor_email"] or ""), a["updated_at"].strftime("%Y-%m-%d %H:%M"), html.escape(a["inhalt"]), tag_pills, a["id"]) return _shell("/wissen", a["titel"], body) @app.route("/wissen//edit", methods=["GET", "POST"]) def wissen_edit_route(article_id): r = _require_login() if r: return r tenant_id = session["tenant_id"] a = db.get_kb_article(tenant_id, article_id) if not a: abort(404) error = None if request.method == "POST": titel = request.form.get("titel", "").strip() kategorie = request.form.get("kategorie", "Allgemein").strip() inhalt = request.form.get("inhalt", "").strip() tags = request.form.get("tags", "").strip() if not titel or not inhalt: error = "Titel und Inhalt duerfen nicht leer sein." else: db.update_kb_article(tenant_id, article_id, titel, kategorie, inhalt, tags) db.log_audit(tenant_id, session["user_id"], "kb_article_updated", "kb_article", str(article_id), {"titel": titel}, _client_ip()) return redirect(url_for("wissen_detail_route", article_id=article_id)) a = dict(a) a.update({"titel": titel, "kategorie": kategorie, "inhalt": inhalt, "tags": tags}) kat_opts = "".join('' % (k, "selected" if k == a["kategorie"] else "", k) for k in KB_KATEGORIEN) err_html = "

%s

" % html.escape(error) if error else "" body = """

Artikel bearbeiten

%s
""" % (err_html, html.escape(a["titel"]), kat_opts, html.escape(a["inhalt"]), html.escape(a["tags"] or ""), article_id) return _shell("/wissen", "Artikel bearbeiten", body) @app.route("/wissen//delete", methods=["POST"]) def wissen_delete_route(article_id): r = _require_login() if r: return r tenant_id = session["tenant_id"] db.delete_kb_article(tenant_id, article_id) db.log_audit(tenant_id, session["user_id"], "kb_article_deleted", "kb_article", str(article_id), None, _client_ip()) return redirect(url_for("wissen_route")) # ── CMDB (Configuration Management Database) ─────────────────────────────────── def _ci_status_pill(status): cls = {"Aktiv": "geloest", "Inaktiv": "geschlossen", "Wartung": "bearbeitung", "Ausgemustert": "ueberfaellig"}.get(status, "offen") return '%s' % (cls, html.escape(status)) def _cmdb_list_body(tenant_id): ci_typ = request.args.get("typ") or None cis = db.list_cis(tenant_id, ci_typ=ci_typ) tabs = 'Alle' % ("active" if not ci_typ else "") tabs += "".join('%s' % (t, "active" if ci_typ == t else "", t) for t in CI_TYPES) rows = [] for c in cis: rows.append(""" %s%s%s%s""" % ( c["id"], html.escape(c["name"]), html.escape(c["ci_typ"]), _ci_status_pill(c["status"]), html.escape(c["beschreibung"] or ""))) table = """%s
NameTypStatusBeschreibung
""" % ("".join(rows) or "Keine Configuration Items.") return ('

CMDB -- Configuration Items

' '
%s
%s' '' % (tabs, table)) @app.route("/assets") def assets_route(): r = _require_login() if r: return r return _shell("/assets", "CMDB", _cmdb_list_body(session["tenant_id"])) @app.route("/assets/new", methods=["GET", "POST"]) def assets_new_route(): r = _require_login() if r: return r tenant_id = session["tenant_id"] error = None name, ci_typ, status, beschreibung, attr_text = "", "Server", "Aktiv", "", "" if request.method == "POST": name = request.form.get("name", "").strip() ci_typ = request.form.get("ci_typ", "Sonstiges").strip() status = request.form.get("status", "Aktiv").strip() beschreibung = request.form.get("beschreibung", "").strip() attr_text = request.form.get("attribute", "") if not name: error = "Name ist erforderlich." else: cid = db.create_ci(tenant_id, name, ci_typ, status, beschreibung, _text_to_attrs(attr_text)) db.log_audit(tenant_id, session["user_id"], "ci_created", "ci", str(cid), {"name": name, "ci_typ": ci_typ}, _client_ip()) return redirect(url_for("assets_detail_route", ci_id=cid)) typ_opts = "".join('' % (t, "selected" if t == ci_typ else "", t) for t in CI_TYPES) status_opts = "".join('' % (s, "selected" if s == status else "", s) for s in CI_STATUS) err_html = "

%s

" % html.escape(error) if error else "" body = """

Neues Configuration Item

%s
""" % (err_html, html.escape(name), typ_opts, status_opts, html.escape(beschreibung), html.escape(attr_text)) return _shell("/assets", "Neues Configuration Item", body) @app.route("/assets/", methods=["GET", "POST"]) def assets_detail_route(ci_id): r = _require_login() if r: return r tenant_id = session["tenant_id"] error = None if request.method == "POST": name = request.form.get("name", "").strip() ci_typ = request.form.get("ci_typ", "Sonstiges").strip() status = request.form.get("status", "Aktiv").strip() beschreibung = request.form.get("beschreibung", "").strip() attr_text = request.form.get("attribute", "") if not name: error = "Name ist erforderlich." else: db.update_ci(tenant_id, ci_id, name, ci_typ, status, beschreibung, _text_to_attrs(attr_text)) db.log_audit(tenant_id, session["user_id"], "ci_updated", "ci", str(ci_id), {"name": name}, _client_ip()) c = db.get_ci(tenant_id, ci_id) if not c: abort(404) typ_opts = "".join('' % (t, "selected" if t == c["ci_typ"] else "", t) for t in CI_TYPES) status_opts = "".join('' % (s, "selected" if s == c["status"] else "", s) for s in CI_STATUS) err_html = "

%s

" % html.escape(error) if error else "" other_cis = [x for x in db.list_cis(tenant_id) if x["id"] != ci_id] ci_opts = "".join('' % (x["id"], html.escape(x["name"]), x["ci_typ"]) for x in other_cis) rel_typ_opts = "".join('' % (t, t) for t in REL_TYPES) rel_out_rows = "".join( "
%s %s
" "
" % ( html.escape(r["beziehungs_typ"]), html.escape(r["to_name"]), ci_id, r["id"]) for r in c["rel_out"] ) or "

Keine ausgehenden Beziehungen.

" rel_in_rows = "".join( "
%s %s (eingehend)
" % ( html.escape(r["beziehungs_typ"]), html.escape(r["from_name"])) for r in c["rel_in"] ) or "

Keine eingehenden Beziehungen.

" body = """

%s

%s

Beziehungen

Ausgehend

%s

Eingehend

%s

Neue Beziehung

""" % ( html.escape(c["name"]), err_html, html.escape(c["name"]), typ_opts, status_opts, html.escape(c["beschreibung"] or ""), _attrs_to_text(c["attribute"]), rel_out_rows, rel_in_rows, ci_id, rel_typ_opts, ci_opts, ) return _shell("/assets", c["name"], body) @app.route("/assets//relationships", methods=["POST"]) def assets_relationship_add_route(ci_id): r = _require_login() if r: return r tenant_id = session["tenant_id"] to_ci_id = request.form.get("to_ci_id", type=int) beziehungs_typ = request.form.get("beziehungs_typ", "haengt ab von").strip() if to_ci_id and to_ci_id != ci_id: rid = db.create_ci_relationship(tenant_id, ci_id, to_ci_id, beziehungs_typ) db.log_audit(tenant_id, session["user_id"], "ci_relationship_created", "ci_relationship", str(rid), {"from": ci_id, "to": to_ci_id, "typ": beziehungs_typ}, _client_ip()) return redirect(url_for("assets_detail_route", ci_id=ci_id)) @app.route("/assets//relationships//delete", methods=["POST"]) def assets_relationship_delete_route(ci_id, rel_id): r = _require_login() if r: return r tenant_id = session["tenant_id"] db.delete_ci_relationship(tenant_id, rel_id) db.log_audit(tenant_id, session["user_id"], "ci_relationship_deleted", "ci_relationship", str(rel_id), None, _client_ip()) return redirect(url_for("assets_detail_route", ci_id=ci_id)) @app.route("/") def root_route(): if not db.any_tenant_exists(): return redirect(url_for("setup_new")) if session.get("user_id"): return redirect(url_for("tickets_route")) return redirect(url_for("login")) @app.route("/health") def health_route(): try: with db.get_conn() as conn: with conn.cursor() as cur: cur.execute("SELECT 1") return jsonify({"status": "ok", "db": "ok"}) except Exception as e: return jsonify({"status": "error", "db": str(e)}), 500 db.init_db() def _retention_loop(): """Hintergrund-Thread: bereinigt abgelaufene Daten periodisch (siehe db.run_retention_cleanup). Laeuft einmal kurz nach dem Start und danach im festen Intervall. Der Postgres-Advisory-Lock in run_retention_cleanup() schuetzt davor, dass mehrere Worker/Prozesse gleichzeitig loeschen.""" time.sleep(30) while True: try: db.run_retention_cleanup() except Exception as e: print("[retention] Fehler bei der Bereinigung:", e, flush=True) time.sleep(RETENTION_INTERVAL_SECONDS) threading.Thread(target=_retention_loop, daemon=True).start() if __name__ == "__main__": app.run(host="0.0.0.0", port=8090)