PostgreSQL-Migration: Mandantenfaehigkeit, Ersteinrichtung, Audit-Log (ISO27001/DSGVO/NIS2)

This commit is contained in:
mscadm 2026-07-13 12:24:37 +00:00
parent 28b8b04a68
commit ccfd1614a5
1 changed files with 375 additions and 269 deletions

632
app.py
View File

@ -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, Betrieb gemaess ISO 27001 / DSGVO / NIS 2 (Nutzer-Vorgabe, 2026-07-13):
mit AES als 'buchbares' Modul." D.h. dieses Repo/Deployment ist NICHT eine - Persistenz in PostgreSQL statt Dateien (Zugriffskontrolle, Backup/Recovery,
Erweiterung des AES-Dashboards, sondern ein eigenes System mit Service-Katalog. Verschluesselung im Ruhezustand ueber die DB moeglich).
AES ist darin nur der erste eingetragene, buchbare Service -- integriert ueber - Audit-Log fuer alle sicherheitsrelevanten/aendernden Aktionen (ISO 27001
die bestehende AES-Dashboard-HTTP-API (Dispatch, Projekt-Status), nicht per A.12.4, DSGVO Art. 30 Rechenschaftspflicht).
Code-Import. Weitere Services (Netzwerk, Cloud, Security, ...) koennen spaeter - Ersteinrichtungs-Assistent (/setup/new): legt pro Kunde einen eigenen
denselben Mechanismus nutzen. 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, AES bleibt ein buchbarer Service im Katalog, integriert per HTTP (kein Code-
gleiches Prinzip wie im AES-Dashboard). Ein Prozess, ein Worker -- Threading-Lock Import) -- siehe _aes_live_status().
schuetzt gegen Nebenlaeufigkeit bei mehreren Gunicorn-Threads.
ENV: ENV:
ITSM_DATA_DIR Verzeichnis fuer tickets.json/services.json/users.json DATABASE_URL postgresql://user:pass@host:5432/dbname
ITSM_USER / ITSM_PASSWORD / ITSM_SECRET_KEY Login + Session-Secret ITSM_SECRET_KEY Flask-Session-Secret
AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration
""" """
import os import os
import json
import html import html
import time import json
import uuid
import secrets import secrets
import hashlib import datetime as _dt
import threading
import urllib.request import urllib.request
import urllib.error import urllib.error
import datetime as _dt
from flask import Flask, request, redirect, url_for, session, jsonify, abort 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("/") AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/")
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32) app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32)
_LOCK = threading.Lock()
CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"] CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"]
PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"] PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"]
STATUSES = ["Offen", "In Bearbeitung", "Warten", "Geloest", "Geschlossen"]
# ── Persistenz ──────────────────────────────────────────────────────────────── def _client_ip():
def _path(name): return request.headers.get("X-Forwarded-For", request.remote_addr or "")
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 _now(): def _now():
return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def _next_ticket_id(tickets): def _jsonable(v):
year = _dt.datetime.utcnow().year if isinstance(v, (_dt.datetime, _dt.date)):
seq = 1 return v.isoformat()
prefix = "TKT-%d-" % year if isinstance(v, dict):
existing = [t["id"] for t in tickets if t["id"].startswith(prefix)] return {k: _jsonable(x) for k, x in v.items()}
if existing: if isinstance(v, list):
nums = [int(i.split("-")[-1]) for i in existing] return [_jsonable(x) for x in v]
seq = max(nums) + 1 return v
return "%s%06d" % (prefix, seq)
def _seed_if_empty(): # ── Auth / Session-Helfer ──────────────────────────────────────────────────────
with _LOCK: def _current_user():
tickets = _load("tickets.json", []) if not session.get("user_id"):
if tickets: return None
return return {
services = _load("services.json", []) "id": session["user_id"],
service_names = [s["name"] for s in services] or ["Allgemein"] "email": session.get("email"),
seed = [ "tenant_id": session.get("tenant_id"),
{ "tenant_name": session.get("tenant_name"),
"id": "TKT-%d-000001" % _dt.datetime.utcnow().year, "role": session.get("role"),
"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
def _require_login(): def _require_login():
if not session.get("user"): if not session.get("user_id"):
return redirect(url_for("login", next=request.path)) return redirect(url_for("login", next=request.path))
return None return None
@app.route("/login", methods=["GET", "POST"]) def _require_admin():
def login(): r = _require_login()
error = None if r:
if request.method == "POST": return r
u = request.form.get("username", "") if session.get("role") != "admin":
p = request.form.get("password", "") return ("<div class='panel'><p class='sz'>Zugriff verweigert -- nur fuer Administratoren.</p></div>", 403)
if _check_login(u, p): return None
session["user"] = u
return redirect(request.args.get("next") or url_for("tickets_route"))
error = "Benutzername oder Passwort falsch."
err_html = "<p style='color:#E5534B;font-size:13px;margin:0 0 10px'>%s</p>" % html.escape(error) if error else ""
return """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anmelden -- ITSM</title>%s</head><body>
<div class="login-wrap"><form class="login-box" method="post">
<h1>ITSM</h1><div class="sz" style="margin-bottom:14px">IT Service Management</div>
%s
<input name="username" placeholder="Benutzername" autofocus>
<input name="password" type="password" placeholder="Passwort">
<button class="btn" type="submit">Anmelden</button>
</form></div></body></html>""" % (_CSS_TAG, err_html)
@app.route("/logout") # ── CSS (an AES-Farbschema angelehnt) ──────────────────────────────────────────
def logout():
session.clear()
return redirect(url_for("login"))
# ── CSS (eigenstaendig, an AES-Farbschema angelehnt aber neu) ─────────────────
_CSS = """ _CSS = """
:root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5; :root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5;
--sub:#93a3b8;--accent:#4c9eba;--accent2:#5db3d0;--ok:#3ecf8e;--warn:#e0a83e; --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{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline} a:hover{text-decoration:underline}
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center} .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 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} 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; .btn{background:var(--accent);border:none;color:#0c1420;font-weight:600;padding:9px 14px;
border-radius:6px;cursor:pointer;font-size:13px} 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} border-radius:6px;padding:7px 12px;color:var(--sub);font-size:13px}
.content{padding:24px} .content{padding:24px}
h1.page-title{font-size:22px;margin:0 0 18px} 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-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{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 .lbl{color:var(--sub);font-size:12px;margin-bottom:6px}
.kpi .val{font-size:26px;font-weight:700} .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{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{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)} .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,table.audit{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 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)} 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 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{border-bottom:none} 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{cursor:pointer}
table.tickets tr.row:hover{background:var(--panel2)} 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{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600}
.pill.offen{background:#2e3a52;color:#9fb3ce} .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.warten{background:#3a2f52;color:#b79ee0}
.pill.geloest{background:#1a4a35;color:var(--ok)} .pill.geloest{background:#1a4a35;color:var(--ok)}
.pill.geschlossen{background:#2a2a2a;color:#888} .pill.geschlossen{background:#2a2a2a;color:#888}
.pill.ueberfaellig{background:#4a1e22;color:var(--bad)} .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.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-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} .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} .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} .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{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{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%} color:var(--text);padding:8px 10px;font-size:13px;width:100%}
textarea{min-height:70px;font-family:inherit} textarea{min-height:70px;font-family:inherit}
.formrow{margin-bottom:10px} .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-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); #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%); 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)} .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{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)} .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 = "<style>" + _CSS + "</style>" _CSS_TAG = "<style>" + _CSS + "</style>"
@ -275,22 +185,18 @@ _NAV = [
("/aenderungen", "Aenderungen"), ("/aenderungen", "Aenderungen"),
("/releases", "Releases"), ("/releases", "Releases"),
]), ]),
("Services", [ ("Services", [("/services", "Service-Katalog")]),
("/services", "Service-Katalog"), ("Wissen", [("/wissen", "Wissensdatenbank")]),
]), ("Assets", [("/assets", "Asset-Liste")]),
("Wissen", [
("/wissen", "Wissensdatenbank"),
]),
("Assets", [
("/assets", "Asset-Liste"),
]),
("Administration", [ ("Administration", [
("/admin", "Einstellungen"), ("/admin", "Einstellungen"),
("/admin/audit", "Audit-Log"),
]), ]),
] ]
def _shell(active_path, title, body_html, extra_head=""): def _shell(active_path, title, body_html, extra_head=""):
u = _current_user()
groups_html = [] groups_html = []
for group_name, items in _NAV: for group_name, items in _NAV:
links = "".join( links = "".join(
@ -299,6 +205,7 @@ def _shell(active_path, title, body_html, extra_head=""):
) )
groups_html.append('<div class="navgroup"><h4>%s</h4>%s</div>' % (html.escape(group_name), links)) groups_html.append('<div class="navgroup"><h4>%s</h4>%s</div>' % (html.escape(group_name), links))
nav_html = "".join(groups_html) nav_html = "".join(groups_html)
who = "%s &middot; %s" % (html.escape(u["email"] or ""), html.escape(u["tenant_name"] or "")) if u else ""
return """<!doctype html><html lang="de"><head><meta charset="utf-8"> return """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s -- ITSM</title>%s%s</head><body> <title>%s -- ITSM</title>%s%s</head><body>
@ -314,19 +221,151 @@ def _shell(active_path, title, body_html, extra_head=""):
</div> </div>
<div id="detail-overlay" onclick="closeDetail()"></div> <div id="detail-overlay" onclick="closeDetail()"></div>
<div id="detail-panel"></div> <div id="detail-panel"></div>
</body></html>""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, html.escape(session.get("user", "")), body_html) </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 = "<p class='err'>%s</p>" % error if error else ""
return """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Neue Organisation einrichten -- ITSM</title>%s</head><body>
<div class="login-wrap"><form class="login-box wide" method="post">
<h1>Neue Organisation einrichten</h1>
<div class="sz" style="margin-bottom:14px">Jede Organisation erhaelt einen eigenen, getrennten Mandanten. Diese Angaben lassen sich unter Einstellungen jederzeit anpassen.</div>
%s
<h2 class="section-title" style="margin-top:0">Firmenprofil</h2>
<div class="formrow"><label>Firmenname</label><input name="firma" required></div>
<h2 class="section-title">Administrator-Konto</h2>
<div class="formrow"><label>E-Mail (Login)</label><input name="email" type="email" required></div>
<div class="grid-2">
<div class="formrow"><label>Passwort</label><input name="password" type="password" required></div>
<div class="formrow"><label>Passwort wiederholen</label><input name="password2" type="password" required></div>
</div>
<div class="hint">Mindestens 10 Zeichen.</div>
<h2 class="section-title">SLA-Standardwerte</h2>
<div class="grid-2">
<div class="formrow"><label>Antwortzeit (Minuten)</label><input name="sla_antwort_minuten" type="number" value="480"></div>
<div class="formrow"><label>Loesungszeit (Minuten)</label><input name="sla_loesung_minuten" type="number" value="2880"></div>
</div>
<h2 class="section-title">Datenschutz (DSGVO)</h2>
<div class="grid-2">
<div class="formrow"><label>Datenschutzbeauftragter/Kontakt</label><input name="dsb_name"></div>
<div class="formrow"><label>Kontakt-E-Mail</label><input name="dsb_email" type="email"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Aufbewahrung Tickets (Tage)</label><input name="retention_tickets_days" type="number" value="1095"></div>
<div class="formrow"><label>Aufbewahrung Audit-Log (Tage)</label><input name="retention_audit_days" type="number" value="1825"></div>
</div>
<div class="hint">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.</div>
<button class="btn" type="submit" style="margin-top:8px">Organisation anlegen</button>
<div class="sz" style="margin-top:12px">Bereits registriert? <a href="/login">Anmelden</a></div>
</form></div></body></html>""" % (_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 = "<p class='err'>%s</p>" % html.escape(error) if error else ""
notice_html = "<p class='sz' style='color:var(--ok);margin:0 0 10px'>%s</p>" % html.escape(notice) if notice else ""
return """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anmelden -- ITSM</title>%s</head><body>
<div class="login-wrap"><form class="login-box" method="post">
<h1>ITSM</h1><div class="sz" style="margin-bottom:14px">IT Service Management</div>
%s%s
<input name="email" type="email" placeholder="E-Mail" autofocus>
<input name="password" type="password" placeholder="Passwort">
<button class="btn" type="submit">Anmelden</button>
<div class="sz" style="margin-top:12px">Neue Organisation? <a href="/setup/new">Jetzt einrichten</a></div>
</form></div></body></html>""" % (_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): def _is_overdue(t):
if t["status"] in ("Geloest", "Geschlossen"): if t["status"] in ("Geloest", "Geschlossen"):
return False return False
try: created = t["created_at"]
created = _dt.datetime.fromisoformat(t["erstellt"].replace("Z", "+00:00")) if created.tzinfo is None:
except Exception: created = created.replace(tzinfo=_dt.timezone.utc)
return False
age_min = (_dt.datetime.now(_dt.timezone.utc) - created).total_seconds() / 60 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): def _kpis(tickets):
@ -339,21 +378,17 @@ def _kpis(tickets):
geloest_7d = 0 geloest_7d = 0
for t in tickets: for t in tickets:
if t["status"] in ("Geloest", "Geschlossen"): if t["status"] in ("Geloest", "Geschlossen"):
try: upd = t["updated_at"]
upd = _dt.datetime.fromisoformat(t["aktualisiert"].replace("Z", "+00:00")) if upd.tzinfo is None:
upd = upd.replace(tzinfo=_dt.timezone.utc)
if (now - upd).days <= 7: if (now - upd).days <= 7:
geloest_7d += 1 geloest_7d += 1
except Exception: return {"Gesamt": total, "Offen": offen, "In Bearbeitung": bearbeitung,
pass "Warten auf Input": warten, "Ueberfaellig": ueberfaellig, "Geloest (7 Tage)": geloest_7d}
return {
"Gesamt": total, "Offen": offen, "In Bearbeitung": bearbeitung,
"Warten auf Input": warten, "Ueberfaellig": ueberfaellig,
"Geloest (7 Tage)": geloest_7d,
}
def _status_class(s): 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") "Geloest": "geloest", "Geschlossen": "geschlossen"}.get(s, "offen")
@ -364,8 +399,8 @@ def _prio_class(p):
def _ticket_row(t): def _ticket_row(t):
status = "ueberfaellig" if _is_overdue(t) else _status_class(t["status"]) status = "ueberfaellig" if _is_overdue(t) else _status_class(t["status"])
status_label = "Ueberfaellig" if _is_overdue(t) else t["status"] status_label = "Ueberfaellig" if _is_overdue(t) else t["status"]
return """<tr class="row" onclick="openDetail('%s')"> return """<tr class="row" onclick="openDetail(%d)">
<td><a href="#" onclick="event.stopPropagation();openDetail('%s');return false">%s</a></td> <td><a href="#" onclick="event.stopPropagation();openDetail(%d);return false">%s</a></td>
<td><b>%s</b><div class="sz">%s</div></td> <td><b>%s</b><div class="sz">%s</div></td>
<td><span class="pill %s">%s</span></td> <td><span class="pill %s">%s</span></td>
<td><span class="pill %s">%s</span></td> <td><span class="pill %s">%s</span></td>
@ -374,9 +409,10 @@ def _ticket_row(t):
<td class="sz">%s</td> <td class="sz">%s</td>
<td><div class="bar-track"><div class="bar-fill" style="width:%s%%"></div></div> %s%%</td> <td><div class="bar-track"><div class="bar-fill" style="width:%s%%"></div></div> %s%%</td>
</tr>""" % ( </tr>""" % (
t["id"], t["id"], t["id"], html.escape(t["titel"]), html.escape(t.get("service", "")), t["id"], t["id"], html.escape(t["ticket_nr"]), html.escape(t["titel"]),
status, status_label, _prio_class(t["prioritaet"]), t["prioritaet"], t["kategorie"], html.escape(t.get("service_name") or ""), status, status_label,
html.escape(t["zugewiesen_an"]), t["aktualisiert"][:16].replace("T", " "), _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"]) t["fortschritt"], t["fortschritt"])
@ -384,8 +420,8 @@ def _kpi_tile(label, value):
return '<div class="kpi"><div class="lbl">%s</div><div class="val">%s</div></div>' % (html.escape(label), value) return '<div class="kpi"><div class="lbl">%s</div><div class="val">%s</div></div>' % (html.escape(label), value)
def _tickets_body(filter_status=None, filter_kategorie=None): def _tickets_body(tenant_id, filter_status=None, filter_kategorie=None):
tickets = sorted(_load("tickets.json", []), key=lambda t: t["aktualisiert"], reverse=True) tickets = db.list_tickets(tenant_id)
kpis = _kpis(tickets) kpis = _kpis(tickets)
kpi_html = "".join(_kpi_tile(k, v) for k, v in kpis.items()) kpi_html = "".join(_kpi_tile(k, v) for k, v in kpis.items())
shown = tickets shown = tickets
@ -405,6 +441,8 @@ def _tickets_body(filter_status=None, filter_kategorie=None):
elif filter_status: elif filter_status:
shown = [t for t in shown if t["status"] == filter_status] shown = [t for t in shown if t["status"] == filter_status]
rows = "".join(_ticket_row(t) for t in shown) or '<tr><td colspan="8" class="sz">Keine Tickets.</td></tr>' rows = "".join(_ticket_row(t) for t in shown) or '<tr><td colspan="8" class="sz">Keine Tickets.</td></tr>'
services = db.list_services(tenant_id)
svc_opts = "".join('<option value="%d">%s</option>' % (s["id"], html.escape(s["name"])) for s in services)
new_form = """<div class="panel"><h3 style="margin-top:0">Neues Ticket</h3> new_form = """<div class="panel"><h3 style="margin-top:0">Neues Ticket</h3>
<form method="post" action="/tickets/new"> <form method="post" action="/tickets/new">
<div class="formrow"><input type="text" name="titel" placeholder="Titel" required></div> <div class="formrow"><input type="text" name="titel" placeholder="Titel" required></div>
@ -413,11 +451,11 @@ def _tickets_body(filter_status=None, filter_kategorie=None):
<div class="formrow"><select name="kategorie">%s</select></div> <div class="formrow"><select name="kategorie">%s</select></div>
<div class="formrow"><select name="prioritaet">%s</select></div> <div class="formrow"><select name="prioritaet">%s</select></div>
</div> </div>
<div class="formrow"><input type="text" name="service" placeholder="Service (optional, z. B. AES)"></div> <div class="formrow"><select name="service_id"><option value="">(kein Service)</option>%s</select></div>
<button class="btn" type="submit">Anlegen</button> <button class="btn" type="submit">Anlegen</button>
</form></div>""" % ( </form></div>""" % (
"".join('<option value="%s">%s</option>' % (c, c) for c in CATEGORIES), "".join('<option value="%s">%s</option>' % (c, c) for c in CATEGORIES),
"".join('<option value="%s">%s</option>' % (p, p) for p in PRIORITIES)) "".join('<option value="%s">%s</option>' % (p, p) for p in PRIORITIES), svc_opts)
return """<h1 class="page-title">Tickets</h1> return """<h1 class="page-title">Tickets</h1>
<div class="kpi-row">%s</div> <div class="kpi-row">%s</div>
%s %s
@ -438,17 +476,25 @@ function closeDetail(){
document.getElementById('detail-panel').classList.remove('open'); document.getElementById('detail-panel').classList.remove('open');
document.getElementById('detail-overlay').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){ function renderDetail(t){
var tl = (t.timeline||[]).slice().reverse().map(function(e){ var tl = (t.timeline||[]).map(function(e){
return '<div class="tl-item"><b>'+e.akteur+'</b><div class="sz">'+e.zeit.replace('T',' ').slice(0,16)+'</div><div>'+e.text+'</div></div>'; return '<div class="tl-item"><b>'+e.akteur+'</b><div class="sz">'+e.zeit.replace('T',' ').slice(0,16)+'</div><div>'+e.text+'</div></div>';
}).join('') || '<div class="sz">Keine Ereignisse.</div>'; }).join('') || '<div class="sz">Keine Ereignisse.</div>';
return '<h2 style="margin-top:0">'+t.id+'</h2>' var statusBtns = ['Offen','In Bearbeitung','Warten','Geloest','Geschlossen'].map(function(s){
return '<button class="btn ghost" style="margin:2px" onclick="setStatus('+t.id+',\\''+s+'\\')">'+s+'</button>';
}).join('');
return '<h2 style="margin-top:0">'+t.ticket_nr+'</h2>'
+ '<div class="sz" style="margin-bottom:10px">'+t.titel+'</div>' + '<div class="sz" style="margin-bottom:10px">'+t.titel+'</div>'
+ '<div><span class="pill '+(t.status||'').toLowerCase()+'">'+t.status+'</span> ' + '<div><span class="pill">'+t.status+'</span> '
+ '<span class="pill">'+t.prioritaet+'</span> <span class="pill">'+t.kategorie+'</span></div>' + '<span class="pill">'+t.prioritaet+'</span> <span class="pill">'+t.kategorie+'</span></div>'
+ '<div class="dt-tabs"><span class="active">Details</span><span>Aktivitaeten</span><span>Anhaenge</span><span>Aufgaben</span></div>' + '<div class="dt-tabs"><span class="active">Details</span><span>Aktivitaeten</span><span>Anhaenge</span><span>Aufgaben</span></div>'
+ '<div class="sz" style="margin-bottom:6px">Beschreibung</div><div style="margin-bottom:14px">'+(t.beschreibung||'')+'</div>' + '<div class="sz" style="margin-bottom:6px">Beschreibung</div><div style="margin-bottom:14px">'+(t.beschreibung||'')+'</div>'
+ '<div class="sz" style="margin-bottom:6px">Service</div><div style="margin-bottom:14px">'+(t.service||'(kein Service)')+'</div>' + '<div class="sz" style="margin-bottom:6px">Service</div><div style="margin-bottom:14px">'+(t.service_name||'(kein Service)')+'</div>'
+ '<div class="sz" style="margin-bottom:6px">Status aendern</div><div style="margin-bottom:14px">'+statusBtns+'</div>'
+ '<div class="sz" style="margin-bottom:6px">Zeitleiste</div>'+tl + '<div class="sz" style="margin-bottom:6px">Zeitleiste</div>'+tl
+ '<div style="margin-top:16px"><button class="btn ghost" onclick="closeDetail()">Schliessen</button></div>'; + '<div style="margin-top:16px"><button class="btn ghost" onclick="closeDetail()">Schliessen</button></div>';
} }
@ -460,7 +506,7 @@ def tickets_route():
r = _require_login() r = _require_login()
if r: if r:
return 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") @app.route("/probleme")
@ -468,7 +514,7 @@ def probleme_route():
r = _require_login() r = _require_login()
if r: if r:
return 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") @app.route("/aenderungen")
@ -476,7 +522,7 @@ def aenderungen_route():
r = _require_login() r = _require_login()
if r: if r:
return 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") @app.route("/releases")
@ -484,7 +530,7 @@ def releases_route():
r = _require_login() r = _require_login()
if r: if r:
return 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"]) @app.route("/tickets/new", methods=["POST"])
@ -492,69 +538,70 @@ def tickets_new_route():
r = _require_login() r = _require_login()
if r: if r:
return r return r
with _LOCK: tenant_id = session["tenant_id"]
tickets = _load("tickets.json", []) service_id = request.form.get("service_id") or None
tid = _next_ticket_id(tickets) tid, ticket_nr = db.create_ticket(
t = { tenant_id,
"id": tid, request.form.get("titel", "").strip() or "(ohne Titel)",
"titel": request.form.get("titel", "").strip() or "(ohne Titel)", request.form.get("beschreibung", "").strip(),
"beschreibung": request.form.get("beschreibung", "").strip(), int(service_id) if service_id else None,
"service": request.form.get("service", "").strip(), request.form.get("prioritaet", "Mittel"),
"status": "Offen", request.form.get("kategorie", "Task"),
"prioritaet": request.form.get("prioritaet", "Mittel"), session.get("email"),
"kategorie": request.form.get("kategorie", "Task"), session["user_id"],
"zugewiesen_an": session.get("user", ITSM_USER), 480, 2880,
"erstellt": _now(), session.get("email"),
"aktualisiert": _now(), )
"fortschritt": 0, db.log_audit(tenant_id, session["user_id"], "ticket_created", "ticket", ticket_nr, None, _client_ip())
"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)
return redirect(url_for("tickets_route")) return redirect(url_for("tickets_route"))
@app.route("/api/tickets/<tid>") @app.route("/tickets/<int:ticket_id>/status", methods=["POST"])
def api_ticket(tid): def ticket_status_route(ticket_id):
r = _require_login() r = _require_login()
if r: if r:
return r return r
tickets = _load("tickets.json", []) tenant_id = session["tenant_id"]
for t in tickets: status = request.form.get("status", "Offen")
if t["id"] == tid: db.update_ticket_status(tenant_id, ticket_id, status, session.get("email"))
return jsonify(t) 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/<int:ticket_id>")
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) abort(404)
return jsonify(_jsonable(ticket))
# ── Service-Katalog ──────────────────────────────────────────────────────────── # ── Service-Katalog ────────────────────────────────────────────────────────────
def _aes_live_status(): 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: if not AES_DASHBOARD_URL:
return None return None
try: 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: with urllib.request.urlopen(req, timeout=4) as resp:
return resp.status == 200 return 200 <= resp.status < 400
except Exception: except Exception:
return None return None
def _services_body(): def _services_body(tenant_id):
services = _load("services.json", []) services = db.list_services(tenant_id)
cards = [] cards = []
for s in services: for s in services:
badge = '<span class="badge">gebucht</span>' if s.get("gebucht") else '<span class="sz">nicht gebucht</span>' badge = '<span class="badge">gebucht</span>' if s["gebucht"] else '<span class="sz">nicht gebucht</span>'
link = ('<div class="sz" style="margin-top:8px"><a href="%s" target="_blank">Zum Service &rarr;</a></div>' link = ('<div class="sz" style="margin-top:8px"><a href="%s" target="_blank">Zum Service &rarr;</a></div>'
% html.escape(s["endpoint"])) if s.get("endpoint") else "" % html.escape(s["endpoint"])) if s.get("endpoint") else ""
cards.append("""<div class="card"><div class="svc-card"> cards.append("""<div class="card"><div class="svc-card">
<div><b>%s</b><div class="sz" style="margin-top:4px">%s</div></div>%s</div>%s</div>""" % ( <div><b>%s</b><div class="sz" style="margin-top:4px">%s</div></div>%s</div>%s</div>""" % (
html.escape(s["name"]), html.escape(s["beschreibung"]), badge, link)) html.escape(s["name"]), html.escape(s["beschreibung"] or ""), badge, link))
add_form = """<div class="panel"><h3 style="margin-top:0">Service hinzufuegen</h3> add_form = """<div class="panel"><h3 style="margin-top:0">Service hinzufuegen</h3>
<form method="post" action="/services/new"> <form method="post" action="/services/new">
<div class="formrow"><input type="text" name="name" placeholder="Name" required></div> <div class="formrow"><input type="text" name="name" placeholder="Name" required></div>
@ -570,7 +617,7 @@ def services_route():
r = _require_login() r = _require_login()
if r: if r:
return 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"]) @app.route("/services/new", methods=["POST"])
@ -578,22 +625,81 @@ def services_new_route():
r = _require_login() r = _require_login()
if r: if r:
return r return r
with _LOCK: tenant_id = session["tenant_id"]
services = _load("services.json", []) sid = db.create_service(
services.append({ tenant_id, request.form.get("name", "").strip(), request.form.get("beschreibung", "").strip(),
"id": uuid.uuid4().hex[:8], "Sonstiges", True, request.form.get("endpoint", "").strip(),
"name": request.form.get("name", "").strip(), )
"beschreibung": request.form.get("beschreibung", "").strip(), db.log_audit(tenant_id, session["user_id"], "service_created", "service", str(sid), None, _client_ip())
"kategorie": "Sonstiges",
"gebucht": True,
"endpoint": request.form.get("endpoint", "").strip(),
"hinzugefuegt": _now(),
})
_save("services.json", services)
return redirect(url_for("services_route")) 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 = "<p class='sz' style='color:var(--ok)'>Gespeichert.</p>"
t = db.get_tenant(tenant_id)
body = """<h1 class="page-title">Einstellungen</h1>
<div class="panel"><h3 style="margin-top:0">%s</h3>%s
<form method="post">
<h2 class="section-title" style="margin-top:0">SLA-Standardwerte</h2>
<div class="grid-2">
<div class="formrow"><label>Antwortzeit (Minuten)</label><input type="number" name="sla_antwort_minuten" value="%s"></div>
<div class="formrow"><label>Loesungszeit (Minuten)</label><input type="number" name="sla_loesung_minuten" value="%s"></div>
</div>
<h2 class="section-title">Datenschutz (DSGVO)</h2>
<div class="grid-2">
<div class="formrow"><label>Datenschutzbeauftragter/Kontakt</label><input name="dsb_name" value="%s"></div>
<div class="formrow"><label>Kontakt-E-Mail</label><input name="dsb_email" value="%s"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Aufbewahrung Tickets (Tage)</label><input type="number" name="retention_tickets_days" value="%s"></div>
<div class="formrow"><label>Aufbewahrung Audit-Log (Tage)</label><input type="number" name="retention_audit_days" value="%s"></div>
</div>
<button class="btn" type="submit">Speichern</button>
</form></div>""" % (
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(
"<tr><td class='sz'>%s</td><td>%s</td><td>%s</td><td class='sz'>%s %s</td></tr>" % (
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 "<tr><td colspan='4' class='sz'>Keine Eintraege.</td></tr>"
body = """<h1 class="page-title">Audit-Log</h1>
<div class="sz" style="margin-bottom:12px">Alle sicherheitsrelevanten Aktionen dieses Mandanten (Anmeldungen, Aenderungen). Aufbewahrung gemaess Einstellungen.</div>
<table class="audit"><thead><tr><th>Zeitpunkt (UTC)</th><th>Benutzer</th><th>Aktion</th><th>Objekt</th></tr></thead>
<tbody>%s</tbody></table>""" % rows
return _shell("/admin/audit", "Audit-Log", body)
# ── Platzhalterseiten ───────────────────────────────────────────────────────────
def _placeholder(title, note): def _placeholder(title, note):
return '<h1 class="page-title">%s</h1><div class="panel"><p class="sz">%s</p></div>' % ( return '<h1 class="page-title">%s</h1><div class="panel"><p class="sz">%s</p></div>' % (
html.escape(title), html.escape(note)) html.escape(title), html.escape(note))
@ -617,28 +723,28 @@ def assets_route():
_placeholder("Asset-Liste", "Noch nicht aufgebaut -- folgt in einer naechsten Ausbaustufe.")) _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("/") @app.route("/")
def root_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("tickets_route"))
return redirect(url_for("login"))
@app.route("/health") @app.route("/health")
def health_route(): 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() db.init_db()
_seed_if_empty()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080) app.run(host="0.0.0.0", port=8090)