diff --git a/app.py b/app.py index 3e6dbf3..57b3857 100644 --- a/app.py +++ b/app.py @@ -1,189 +1,94 @@ """ -ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES). +ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES), mandantenfaehig. -Architektur-Entscheidung (2026-07-13, Nutzer-Vorgabe): "Es soll ein ITSM werden, -mit AES als 'buchbares' Modul." D.h. dieses Repo/Deployment ist NICHT eine -Erweiterung des AES-Dashboards, sondern ein eigenes System mit Service-Katalog. -AES ist darin nur der erste eingetragene, buchbare Service -- integriert ueber -die bestehende AES-Dashboard-HTTP-API (Dispatch, Projekt-Status), nicht per -Code-Import. Weitere Services (Netzwerk, Cloud, Security, ...) koennen spaeter -denselben Mechanismus nutzen. +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). -Persistenz: einfache JSON-Dateien unter DATA_DIR (kein Datenbankserver noetig, -gleiches Prinzip wie im AES-Dashboard). Ein Prozess, ein Worker -- Threading-Lock -schuetzt gegen Nebenlaeufigkeit bei mehreren Gunicorn-Threads. +AES bleibt ein buchbarer Service im Katalog, integriert per HTTP (kein Code- +Import) -- siehe _aes_live_status(). ENV: - ITSM_DATA_DIR Verzeichnis fuer tickets.json/services.json/users.json - ITSM_USER / ITSM_PASSWORD / ITSM_SECRET_KEY Login + Session-Secret - AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration + 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 """ import os -import json import html -import time -import uuid +import json import secrets -import hashlib -import threading +import datetime as _dt import urllib.request import urllib.error -import datetime as _dt from flask import Flask, request, redirect, url_for, session, jsonify, abort +from werkzeug.security import generate_password_hash, check_password_hash + +import db -DATA_DIR = os.getenv("ITSM_DATA_DIR", "/data") -ITSM_USER = os.getenv("ITSM_USER", "admin") -ITSM_PASSWORD = os.getenv("ITSM_PASSWORD", "") AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/") app = Flask(__name__) app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32) -_LOCK = threading.Lock() - CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"] PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"] -STATUSES = ["Offen", "In Bearbeitung", "Warten", "Geloest", "Geschlossen"] -# ── Persistenz ──────────────────────────────────────────────────────────────── -def _path(name): - return os.path.join(DATA_DIR, name) - - -def _load(name, default): - p = _path(name) - if not os.path.isfile(p): - return default - try: - with open(p, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return default - - -def _save(name, data): - os.makedirs(DATA_DIR, exist_ok=True) - p = _path(name) - tmp = p + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - os.replace(tmp, p) +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 _next_ticket_id(tickets): - year = _dt.datetime.utcnow().year - seq = 1 - prefix = "TKT-%d-" % year - existing = [t["id"] for t in tickets if t["id"].startswith(prefix)] - if existing: - nums = [int(i.split("-")[-1]) for i in existing] - seq = max(nums) + 1 - return "%s%06d" % (prefix, seq) +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 -def _seed_if_empty(): - with _LOCK: - tickets = _load("tickets.json", []) - if tickets: - return - services = _load("services.json", []) - service_names = [s["name"] for s in services] or ["Allgemein"] - seed = [ - { - "id": "TKT-%d-000001" % _dt.datetime.utcnow().year, - "titel": "Willkommen im ITSM", - "beschreibung": ( - "Dies ist ein Beispiel-Ticket. Ueber 'Neues Ticket' oben rechts " - "kannst du echte Vorgaenge anlegen. Tickets koennen optional " - "einem Service aus dem Katalog zugeordnet werden (z. B. AES)." - ), - "service": service_names[0], - "status": "Offen", - "prioritaet": "Niedrig", - "kategorie": "Task", - "zugewiesen_an": ITSM_USER, - "erstellt": _now(), - "aktualisiert": _now(), - "fortschritt": 0, - "betroffene_services": [], - "ci_elemente": [], - "sla_antwort_minuten": 480, - "sla_loesung_minuten": 2880, - "timeline": [{"zeit": _now(), "akteur": "System", "text": "Ticket angelegt."}], - } - ] - _save("tickets.json", seed) - - -def _seed_services_if_empty(): - with _LOCK: - services = _load("services.json", []) - if services: - return - services = [ - { - "id": "aes", - "name": "AES -- Autonomous Engineering System", - "beschreibung": ( - "Automatisierte Software-Entwicklung: Projekte, Phasen, " - "LLM-gestuetzte Agenten, Auto-Fix und Release-Pipeline." - ), - "kategorie": "Entwicklung", - "gebucht": True, - "endpoint": AES_DASHBOARD_URL, - "hinzugefuegt": _now(), - } - ] - _save("services.json", services) - - -# ── Auth ────────────────────────────────────────────────────────────────────── -def _check_login(user, pw): - return user == ITSM_USER and ITSM_PASSWORD and pw == ITSM_PASSWORD +# ── 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"): + if not session.get("user_id"): return redirect(url_for("login", next=request.path)) return None -@app.route("/login", methods=["GET", "POST"]) -def login(): - error = None - if request.method == "POST": - u = request.form.get("username", "") - p = request.form.get("password", "") - if _check_login(u, p): - session["user"] = u - return redirect(request.args.get("next") or url_for("tickets_route")) - error = "Benutzername oder Passwort falsch." - err_html = "

%s

" % html.escape(error) if error else "" - return """ - -Anmelden -- ITSM%s -
-

ITSM

IT Service Management
-%s - - - -
""" % (_CSS_TAG, err_html) +def _require_admin(): + r = _require_login() + if r: + return r + if session.get("role") != "admin": + return ("

Zugriff verweigert -- nur fuer Administratoren.

", 403) + return None -@app.route("/logout") -def logout(): - session.clear() - return redirect(url_for("login")) - - -# ── CSS (eigenstaendig, an AES-Farbschema angelehnt aber neu) ───────────────── +# ── 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; @@ -193,9 +98,10 @@ body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,S 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:300px} +.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{width:100%;padding:9px 10px;margin-bottom:10px;background:var(--bg); +.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} @@ -219,30 +125,30 @@ a:hover{text-decoration:underline} 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} -.kpi .trend{font-size:11px;margin-top:4px} -.kpi .trend.up{color:var(--ok)} .kpi .trend.down{color:var(--bad)} .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{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden} -table.tickets th{text-align:left;font-size:11px;text-transform:uppercase;color:var(--sub); +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{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top} -table.tickets tr:last-child td{border-bottom:none} +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{background:#4a3a1a;color:var(--warn)} +.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} @@ -250,10 +156,12 @@ table.tickets tr.row:hover{background:var(--panel2)} .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)} -input[type=text],textarea,select{background:var(--bg);border:1px solid var(--border);border-radius:6px; +input[type=text],input[type=email],input[type=password],input[type=number],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} .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%); @@ -265,6 +173,8 @@ textarea{min-height:70px;font-family:inherit} .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 = "" @@ -275,22 +185,18 @@ _NAV = [ ("/aenderungen", "Aenderungen"), ("/releases", "Releases"), ]), - ("Services", [ - ("/services", "Service-Katalog"), - ]), - ("Wissen", [ - ("/wissen", "Wissensdatenbank"), - ]), - ("Assets", [ - ("/assets", "Asset-Liste"), - ]), + ("Services", [("/services", "Service-Katalog")]), + ("Wissen", [("/wissen", "Wissensdatenbank")]), + ("Assets", [("/assets", "Asset-Liste")]), ("Administration", [ ("/admin", "Einstellungen"), + ("/admin/audit", "Audit-Log"), ]), ] def _shell(active_path, title, body_html, extra_head=""): + u = _current_user() groups_html = [] for group_name, items in _NAV: links = "".join( @@ -299,6 +205,7 @@ def _shell(active_path, title, body_html, extra_head=""): ) 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 @@ -314,19 +221,151 @@ def _shell(active_path, title, body_html, extra_head=""):
-""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, html.escape(session.get("user", "")), body_html) +""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, who, body_html) -# ── Tickets: Berechnung + Rendering ─────────────────────────────────────────── +# ── Ersteinrichtung (neuer Mandant) ──────────────────────────────────────────── +@app.route("/setup/new", methods=["GET", "POST"]) +def setup_new(): + error = None + 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). Die automatische Loeschung nach Fristablauf ist als naechster Ausbauschritt vorgesehen -- aktuell wird nur die Frist hinterlegt.
+ +
Bereits registriert? Anmelden
+
""" % (_CSS_TAG, err_html) + + +# ── 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." + 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 "" + return """ + +Anmelden -- ITSM%s +
+

ITSM

IT Service Management
+%s%s + + + +
Neue Organisation? Jetzt einrichten
+
""" % (_CSS_TAG, err_html, notice_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 - try: - created = _dt.datetime.fromisoformat(t["erstellt"].replace("Z", "+00:00")) - except Exception: - 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", 999999) + return age_min > (t.get("sla_loesung_minuten") or 999999) def _kpis(tickets): @@ -339,21 +378,17 @@ def _kpis(tickets): geloest_7d = 0 for t in tickets: if t["status"] in ("Geloest", "Geschlossen"): - try: - upd = _dt.datetime.fromisoformat(t["aktualisiert"].replace("Z", "+00:00")) - if (now - upd).days <= 7: - geloest_7d += 1 - except Exception: - pass - return { - "Gesamt": total, "Offen": offen, "In Bearbeitung": bearbeitung, - "Warten auf Input": warten, "Ueberfaellig": ueberfaellig, - "Geloest (7 Tage)": geloest_7d, - } + 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": "bearbeitung", "Warten": "warten", + return {"Offen": "offen", "In Bearbeitung": "inbearbeitung", "Warten": "warten", "Geloest": "geloest", "Geschlossen": "geschlossen"}.get(s, "offen") @@ -364,8 +399,8 @@ def _prio_class(p): 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 + return """ + %s %s
%s
%s %s @@ -374,9 +409,10 @@ def _ticket_row(t): %s
%s%% """ % ( - t["id"], t["id"], t["id"], html.escape(t["titel"]), html.escape(t.get("service", "")), - status, status_label, _prio_class(t["prioritaet"]), t["prioritaet"], t["kategorie"], - html.escape(t["zugewiesen_an"]), t["aktualisiert"][:16].replace("T", " "), + 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"]) @@ -384,8 +420,8 @@ def _kpi_tile(label, value): return '
%s
%s
' % (html.escape(label), value) -def _tickets_body(filter_status=None, filter_kategorie=None): - tickets = sorted(_load("tickets.json", []), key=lambda t: t["aktualisiert"], reverse=True) +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 @@ -405,6 +441,8 @@ def _tickets_body(filter_status=None, filter_kategorie=None): 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

@@ -413,11 +451,11 @@ def _tickets_body(filter_status=None, filter_kategorie=None):
-
+
""" % ( "".join('' % (c, c) for c in CATEGORIES), - "".join('' % (p, p) for p in PRIORITIES)) + "".join('' % (p, p) for p in PRIORITIES), svc_opts) return """

Tickets

%s
%s @@ -438,17 +476,25 @@ function closeDetail(){ document.getElementById('detail-panel').classList.remove('open'); document.getElementById('detail-overlay').classList.remove('open'); } +function setStatus(id, status){ + fetch('/tickets/' + id + '/status', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'status=' + encodeURIComponent(status)}) + .then(() => openDetail(id)); +} function renderDetail(t){ - var tl = (t.timeline||[]).slice().reverse().map(function(e){ + var tl = (t.timeline||[]).map(function(e){ return '
'+e.akteur+'
'+e.zeit.replace('T',' ').slice(0,16)+'
'+e.text+'
'; }).join('') || '
Keine Ereignisse.
'; - return '

'+t.id+'

' + var statusBtns = ['Offen','In Bearbeitung','Warten','Geloest','Geschlossen'].map(function(s){ + return ''; + }).join(''); + return '

'+t.ticket_nr+'

' + '
'+t.titel+'
' - + '
'+t.status+' ' + + '
'+t.status+' ' + ''+t.prioritaet+' '+t.kategorie+'
' + '
DetailsAktivitaetenAnhaengeAufgaben
' + '
Beschreibung
'+(t.beschreibung||'')+'
' - + '
Service
'+(t.service||'(kein Service)')+'
' + + '
Service
'+(t.service_name||'(kein Service)')+'
' + + '
Status aendern
'+statusBtns+'
' + '
Zeitleiste
'+tl + '
'; } @@ -460,7 +506,7 @@ def tickets_route(): r = _require_login() if r: return r - return _shell("/tickets", "Tickets", _tickets_body(filter_status=request.args.get("status"))) + return _shell("/tickets", "Tickets", _tickets_body(session["tenant_id"], request.args.get("status"))) @app.route("/probleme") @@ -468,7 +514,7 @@ def probleme_route(): r = _require_login() if r: return r - return _shell("/probleme", "Probleme", _tickets_body(request.args.get("status"), "Problem")) + return _shell("/probleme", "Probleme", _tickets_body(session["tenant_id"], request.args.get("status"), "Problem")) @app.route("/aenderungen") @@ -476,7 +522,7 @@ def aenderungen_route(): r = _require_login() if r: return r - return _shell("/aenderungen", "Aenderungen", _tickets_body(request.args.get("status"), "Change")) + return _shell("/aenderungen", "Aenderungen", _tickets_body(session["tenant_id"], request.args.get("status"), "Change")) @app.route("/releases") @@ -484,7 +530,7 @@ def releases_route(): r = _require_login() if r: return r - return _shell("/releases", "Releases", _tickets_body(request.args.get("status"), "Release")) + return _shell("/releases", "Releases", _tickets_body(session["tenant_id"], request.args.get("status"), "Release")) @app.route("/tickets/new", methods=["POST"]) @@ -492,69 +538,70 @@ def tickets_new_route(): r = _require_login() if r: return r - with _LOCK: - tickets = _load("tickets.json", []) - tid = _next_ticket_id(tickets) - t = { - "id": tid, - "titel": request.form.get("titel", "").strip() or "(ohne Titel)", - "beschreibung": request.form.get("beschreibung", "").strip(), - "service": request.form.get("service", "").strip(), - "status": "Offen", - "prioritaet": request.form.get("prioritaet", "Mittel"), - "kategorie": request.form.get("kategorie", "Task"), - "zugewiesen_an": session.get("user", ITSM_USER), - "erstellt": _now(), - "aktualisiert": _now(), - "fortschritt": 0, - "betroffene_services": [], - "ci_elemente": [], - "sla_antwort_minuten": 480, - "sla_loesung_minuten": 2880, - "timeline": [{"zeit": _now(), "akteur": session.get("user", ITSM_USER), "text": "Ticket angelegt."}], - } - tickets.append(t) - _save("tickets.json", tickets) + 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("/api/tickets/") -def api_ticket(tid): +@app.route("/tickets//status", methods=["POST"]) +def ticket_status_route(ticket_id): r = _require_login() if r: return r - tickets = _load("tickets.json", []) - for t in tickets: - if t["id"] == tid: - return jsonify(t) - abort(404) + 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}) -# ── Service-Katalog ──────────────────────────────────────────────────────────── +@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 _aes_live_status(): - """Fragt den Status des AES-Service live ueber dessen Dashboard-API ab - (kein Code-Import -- ITSM und AES sind getrennte Systeme, verbunden nur - per HTTP, wie es die Rolle 'AES als buchbarer Service' verlangt).""" if not AES_DASHBOARD_URL: return None try: - req = urllib.request.Request(AES_DASHBOARD_URL + "/health", method="GET") + req = urllib.request.Request(AES_DASHBOARD_URL + "/", method="GET") with urllib.request.urlopen(req, timeout=4) as resp: - return resp.status == 200 + return 200 <= resp.status < 400 except Exception: return None -def _services_body(): - services = _load("services.json", []) +def _services_body(tenant_id): + services = db.list_services(tenant_id) cards = [] for s in services: - badge = 'gebucht' if s.get("gebucht") else 'nicht gebucht' + badge = 'gebucht' if s["gebucht"] else 'nicht 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"]), badge, link)) + html.escape(s["name"]), html.escape(s["beschreibung"] or ""), badge, link)) add_form = """

Service hinzufuegen

@@ -570,7 +617,7 @@ def services_route(): r = _require_login() if r: return r - return _shell("/services", "Service-Katalog", _services_body()) + return _shell("/services", "Service-Katalog", _services_body(session["tenant_id"])) @app.route("/services/new", methods=["POST"]) @@ -578,22 +625,81 @@ def services_new_route(): r = _require_login() if r: return r - with _LOCK: - services = _load("services.json", []) - services.append({ - "id": uuid.uuid4().hex[:8], - "name": request.form.get("name", "").strip(), - "beschreibung": request.form.get("beschreibung", "").strip(), - "kategorie": "Sonstiges", - "gebucht": True, - "endpoint": request.form.get("endpoint", "").strip(), - "hinzugefuegt": _now(), - }) - _save("services.json", services) + 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")) -# ── Platzhalterseiten (Wissen/Assets/Administration) ────────────────────────── +# ── 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.

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

Einstellungen

+

%s

%s + +

SLA-Standardwerte

+
+
+
+
+

Datenschutz (DSGVO)

+
+
+
+
+
+
+
+
+ +
""" % ( + 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/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) + + +# ── Platzhalterseiten ─────────────────────────────────────────────────────────── def _placeholder(title, note): return '

%s

%s

' % ( html.escape(title), html.escape(note)) @@ -617,28 +723,28 @@ def assets_route(): _placeholder("Asset-Liste", "Noch nicht aufgebaut -- folgt in einer naechsten Ausbaustufe.")) -@app.route("/admin") -def admin_route(): - r = _require_login() - if r: - return r - return _shell("/admin", "Einstellungen", - _placeholder("Einstellungen", "Benutzer/Rollen/Workflows folgen in einer naechsten Ausbaustufe.")) - - @app.route("/") def root_route(): - return redirect(url_for("tickets_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(): - return jsonify({"status": "ok"}) + 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 -_seed_services_if_empty() -_seed_if_empty() +db.init_db() if __name__ == "__main__": - app.run(host="0.0.0.0", port=8080) + app.run(host="0.0.0.0", port=8090)