1369 lines
66 KiB
Python
1369 lines
66 KiB
Python
"""
|
|
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 ("<div class='panel'><p class='sz'>Zugriff verweigert -- nur fuer Administratoren.</p></div>", 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)}
|
|
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 = "<style>" + _CSS + "</style>"
|
|
|
|
_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(
|
|
'<a href="%s" class="%s">%s</a>' % (href, "active" if href == active_path else "", html.escape(label))
|
|
for href, label in items
|
|
)
|
|
groups_html.append('<div class="navgroup"><h4>%s</h4>%s</div>' % (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 """<!doctype html><html lang="de"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>%s -- ITSM</title>%s%s</head><body>
|
|
<div class="shell">
|
|
<div class="sidebar"><div class="brand">ITSM<div class="sz">IT Service Management</div></div>%s</div>
|
|
<div class="main">
|
|
<div class="topbar">
|
|
<input class="search" placeholder="Suche (Tickets, Services, Wissen ...)">
|
|
<div class="sz">%s · <a href="/logout">Abmelden</a></div>
|
|
</div>
|
|
<div class="content">%s</div>
|
|
</div>
|
|
</div>
|
|
<div id="detail-overlay" onclick="closeDetail()"></div>
|
|
<div id="detail-panel"></div>
|
|
</body></html>""" % (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 = "<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" value="%s" required></div>
|
|
<h2 class="section-title">Administrator-Konto</h2>
|
|
<div class="formrow"><label>E-Mail (Login)</label><input name="email" type="email" value="%s" 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="%s"></div>
|
|
<div class="formrow"><label>Loesungszeit (Minuten)</label><input name="sla_loesung_minuten" type="number" 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" type="email" value="%s"></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Aufbewahrung Tickets (Tage)</label><input name="retention_tickets_days" type="number" value="%s"></div>
|
|
<div class="formrow"><label>Aufbewahrung Audit-Log (Tage)</label><input name="retention_audit_days" type="number" value="%s"></div>
|
|
</div>
|
|
<div class="hint">Diese Werte legen fest, wie lange Tickets bzw. Audit-Eintraege vorgehalten werden (DSGVO Speicherbegrenzung). Ein Hintergrund-Job bereinigt automatisch alle 24 Stunden.</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,
|
|
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 = "<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 ""
|
|
# 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 = ('<div class="sz" style="margin-top:12px">Neue Organisation? '
|
|
'<a href="/setup/new">Jetzt einrichten</a></div>') if not db.any_tenant_exists() 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>
|
|
%s
|
|
</form></div></body></html>""" % (_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 """<tr class="row" onclick="openDetail(%d)">
|
|
<td><a href="#" onclick="event.stopPropagation();openDetail(%d);return false">%s</a></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>%s</td>
|
|
<td>%s</td>
|
|
<td class="sz">%s</td>
|
|
<td><div class="bar-track"><div class="bar-fill" style="width:%s%%"></div></div> %s%%</td>
|
|
</tr>""" % (
|
|
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 '<div class="kpi"><div class="lbl">%s</div><div class="val">%s</div></div>' % (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(
|
|
'<a href="%s%s" class="%s">%s</a>' % (
|
|
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 '<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>
|
|
<form method="post" action="/tickets/new">
|
|
<div class="formrow"><input type="text" name="titel" placeholder="Titel" required></div>
|
|
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><select name="kategorie">%s</select></div>
|
|
<div class="formrow"><select name="prioritaet">%s</select></div>
|
|
</div>
|
|
<div class="formrow"><select name="service_id"><option value="">(kein Service)</option>%s</select></div>
|
|
<button class="btn" type="submit">Anlegen</button>
|
|
</form></div>""" % (
|
|
"".join('<option value="%s">%s</option>' % (c, c) for c in CATEGORIES),
|
|
"".join('<option value="%s">%s</option>' % (p, p) for p in PRIORITIES), svc_opts)
|
|
return """<h1 class="page-title">Tickets</h1>
|
|
<div class="kpi-row">%s</div>
|
|
%s
|
|
<div class="tabs">%s</div>
|
|
<table class="tickets"><thead><tr>
|
|
<th>Ticket-Nr</th><th>Titel (Service)</th><th>Status</th><th>Prioritaet</th>
|
|
<th>Kategorie</th><th>Zugewiesen an</th><th>Aktualisiert</th><th>Fortschritt</th>
|
|
</tr></thead><tbody>%s</tbody></table>
|
|
<script>
|
|
function openDetail(id){
|
|
fetch('/api/tickets/' + id).then(r => r.json()).then(t => {
|
|
document.getElementById('detail-panel').innerHTML = renderDetail(t);
|
|
document.getElementById('detail-panel').classList.add('open');
|
|
document.getElementById('detail-overlay').classList.add('open');
|
|
});
|
|
}
|
|
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||[]).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>';
|
|
}).join('') || '<div class="sz">Keine Ereignisse.</div>';
|
|
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><span class="pill">'+t.status+'</span> '
|
|
+ '<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="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_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 style="margin-top:16px"><button class="btn ghost" onclick="closeDetail()">Schliessen</button></div>';
|
|
}
|
|
</script>""" % (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/<int:ticket_id>/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/<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)
|
|
return jsonify(_jsonable(ticket))
|
|
|
|
|
|
# ── Service-Katalog ─────────────────────────────────────────────────────────────
|
|
def _aes_live_status():
|
|
if not AES_DASHBOARD_URL:
|
|
return None
|
|
try:
|
|
req = urllib.request.Request(AES_DASHBOARD_URL + "/", method="GET")
|
|
with urllib.request.urlopen(req, timeout=4) as resp:
|
|
return 200 <= resp.status < 400
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _services_body(tenant_id):
|
|
services = db.list_services(tenant_id)
|
|
cards = []
|
|
for s in services:
|
|
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 →</a></div>'
|
|
% html.escape(s["endpoint"])) if s.get("endpoint") else ""
|
|
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>""" % (
|
|
html.escape(s["name"]), html.escape(s["beschreibung"] or ""), badge, link))
|
|
add_form = """<div class="panel"><h3 style="margin-top:0">Service hinzufuegen</h3>
|
|
<form method="post" action="/services/new">
|
|
<div class="formrow"><input type="text" name="name" placeholder="Name" required></div>
|
|
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
|
|
<div class="formrow"><input type="text" name="endpoint" placeholder="Endpoint-URL (optional)"></div>
|
|
<button class="btn" type="submit">Hinzufuegen</button>
|
|
</form></div>"""
|
|
return '<h1 class="page-title">Service-Katalog</h1>' + "".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 = "<p class='sz' style='color:var(--ok)'>Gespeichert.</p>"
|
|
if request.args.get("cleanup"):
|
|
notice += "<p class='sz' style='color:var(--ok)'>Bereinigung durchgefuehrt -- Details im Audit-Log.</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>
|
|
<div class="panel">
|
|
<h3 style="margin-top:0">Aufbewahrungsfrist-Bereinigung</h3>
|
|
<p class="sz">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.</p>
|
|
<form method="post" action="/admin/retention/run">
|
|
<button class="btn ghost" type="submit">Jetzt manuell bereinigen</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/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(
|
|
"<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)
|
|
|
|
|
|
# ── Administration: Benutzerverwaltung ─────────────────────────────────────────
|
|
def _role_pill(role):
|
|
return '<span class="pill %s">%s</span>' % (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 = ('<span class="pill admin" style="background:#1a4a35;color:var(--ok)">aktiv</span>'
|
|
if u["active"] else
|
|
'<span class="pill" style="background:#4a1e22;color:var(--bad)">gesperrt</span>')
|
|
toggle_label = "Sperren" if u["active"] else "Entsperren"
|
|
role_opts = "".join(
|
|
'<option value="%s" %s>%s</option>' % (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("""<tr>
|
|
<td>%s</td>
|
|
<td class="sz">%s</td>
|
|
<td>%s</td>
|
|
<td>%s</td>
|
|
<td class="sz">%s</td>
|
|
<td class="sz">%s</td>
|
|
<td class="sz">%s</td>
|
|
<td>
|
|
<a class="btn ghost" href="/admin/users/%s/edit" style="padding:4px 10px;font-size:12px">Details</a>
|
|
<form method="post" action="/admin/users/%s/role" style="display:inline">
|
|
<select name="role" onchange="this.form.submit()">%s</select>
|
|
</form>
|
|
<form method="post" action="/admin/users/%s/active" style="display:inline">
|
|
<button class="btn ghost" type="submit" style="margin-left:6px">%s</button>
|
|
</form>
|
|
</td>
|
|
</tr>""" % (
|
|
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 = """<table class="tickets"><thead><tr>
|
|
<th>E-Mail</th><th>Name</th><th>Rolle</th><th>Status</th><th>Telefon</th><th>Quelle</th><th>Letzte Anmeldung</th><th>Aktionen</th>
|
|
</tr></thead><tbody>%s</tbody></table>""" % ("".join(rows) or "<tr><td colspan='8' class='sz'>Keine Benutzer.</td></tr>")
|
|
|
|
open_attr = " open" if (notice or fv) else ""
|
|
add_form = """<details%s style="margin-top:16px">
|
|
<summary class="btn" style="width:auto;display:inline-block">+ Neuen Benutzer</summary>
|
|
<div class="panel" style="margin-top:10px"><h3 style="margin-top:0">Neuen Benutzer anlegen</h3>
|
|
<div class="sz" style="margin-bottom:10px">Manuelle Verwaltung. Eine AD/LDAP-Anbindung ist als naechste Ausbaustufe vorgesehen (Spalte "Quelle" zeigt dann "ldap" statt "local").</div>
|
|
<form method="post" action="/admin/users">
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>E-Mail</label><input type="email" name="email" value="%s" required></div>
|
|
<div class="formrow"><label>Rolle</label><select name="role"><option value="agent" %s>agent</option><option value="admin" %s>admin</option></select></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Passwort</label><input type="password" name="password" required></div>
|
|
<div class="formrow"><label>Passwort wiederholen</label><input type="password" name="password2" required></div>
|
|
</div>
|
|
<div class="hint">Mindestens 10 Zeichen. Der Benutzer sollte das Passwort nach der ersten Anmeldung selbst aendern.</div>
|
|
<h2 class="section-title">Details (optional)</h2>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Vorname</label><input type="text" name="vorname" value="%s"></div>
|
|
<div class="formrow"><label>Nachname</label><input type="text" name="nachname" value="%s"></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Telefon</label><input type="text" name="telefon" value="%s"></div>
|
|
<div class="formrow"><label>Abteilung</label><input type="text" name="abteilung" value="%s"></div>
|
|
</div>
|
|
<div class="formrow"><label>Adresse</label><input type="text" name="adresse" value="%s"></div>
|
|
<button class="btn" type="submit">Anlegen</button>
|
|
</form></div></details>""" % (
|
|
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 '<h1 class="page-title">Benutzerverwaltung</h1>' + 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 = "<p class='err'>Bitte eine gueltige E-Mail-Adresse angeben.</p>"
|
|
elif len(pw) < 10:
|
|
notice = "<p class='err'>Das Passwort muss mindestens 10 Zeichen lang sein.</p>"
|
|
elif pw != pw2:
|
|
notice = "<p class='err'>Die Passwoerter stimmen nicht ueberein.</p>"
|
|
elif db.email_exists(email):
|
|
notice = "<p class='err'>Diese E-Mail-Adresse ist bereits registriert.</p>"
|
|
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 = "<p class='sz' style='color:var(--ok)'>Benutzer angelegt.</p>"
|
|
form_vals = {}
|
|
return _shell("/admin/users", "Benutzerverwaltung", _users_body(tenant_id, notice, form_vals))
|
|
|
|
|
|
@app.route("/admin/users/<int:user_id>/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
|
|
if request.method == "POST":
|
|
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()
|
|
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({"vorname": vorname, "nachname": nachname, "telefon": telefon,
|
|
"abteilung": abteilung, "adresse": adresse})
|
|
error = "Gespeichert."
|
|
err_html = ("<p class='sz' style='color:var(--ok)'>%s</p>" % html.escape(error)) if error else ""
|
|
body = """<h1 class="page-title">Benutzerdetails</h1>
|
|
<div class="sz" style="margin-bottom:14px">%s</div>
|
|
<div class="panel">%s<form method="post">
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Vorname</label><input name="vorname" value="%s"></div>
|
|
<div class="formrow"><label>Nachname</label><input name="nachname" value="%s"></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Telefon</label><input name="telefon" value="%s"></div>
|
|
<div class="formrow"><label>Abteilung</label><input name="abteilung" value="%s"></div>
|
|
</div>
|
|
<div class="formrow"><label>Adresse</label><input name="adresse" value="%s"></div>
|
|
<button class="btn" type="submit">Speichern</button>
|
|
</form></div>
|
|
<a class="btn ghost" href="/admin/users">Zurueck zur Benutzerverwaltung</a>""" % (
|
|
html.escape(u["email"]), err_html,
|
|
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/<int:user_id>/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/<int:user_id>/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(
|
|
'<a href="/wissen?kategorie=%s" class="%s">%s</a>' % (
|
|
html.escape(k), "active" if kategorie == k else "", html.escape(k))
|
|
for k in KB_KATEGORIEN
|
|
)
|
|
tabs = '<a href="/wissen" class="%s">Alle</a>' % ("active" if not kategorie else "") + tabs
|
|
rows = []
|
|
for a in articles:
|
|
tag_pills = "".join('<span class="pill agent" style="margin-right:4px">%s</span>' % html.escape(t.strip())
|
|
for t in (a["tags"] or "").split(",") if t.strip())
|
|
rows.append("""<tr class="row" onclick="location.href='/wissen/%s'">
|
|
<td><b>%s</b></td><td><span class="pill">%s</span></td><td>%s</td>
|
|
<td class="sz">%s</td><td class="sz">%s</td></tr>""" % (
|
|
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 = """<table class="tickets"><thead><tr>
|
|
<th>Titel</th><th>Kategorie</th><th>Tags</th><th>Autor</th><th>Aktualisiert</th>
|
|
</tr></thead><tbody>%s</tbody></table>""" % ("".join(rows) or "<tr><td colspan='5' class='sz'>Keine Artikel.</td></tr>")
|
|
search_form = """<form method="get" action="/wissen" style="margin-bottom:14px">
|
|
<input type="text" name="q" placeholder="Suche in Titel, Inhalt, Tags ..." value="%s" style="max-width:420px;display:inline-block">
|
|
<button class="btn ghost" type="submit">Suchen</button>
|
|
</form>""" % html.escape(q or "")
|
|
return ('<h1 class="page-title">Wissensdatenbank</h1>'
|
|
'<div class="tabs">%s</div>%s%s'
|
|
'<div style="margin-top:14px"><a class="btn" href="/wissen/new">+ Neuer Artikel</a></div>' % (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('<option value="%s" %s>%s</option>' % (k, "selected" if k == kategorie else "", k)
|
|
for k in KB_KATEGORIEN)
|
|
err_html = "<p class='err'>%s</p>" % html.escape(error) if error else ""
|
|
body = """<h1 class="page-title">Neuer Wissensartikel</h1>
|
|
<div class="panel">%s<form method="post">
|
|
<div class="formrow"><label>Titel</label><input name="titel" value="%s" required></div>
|
|
<div class="formrow"><label>Kategorie</label><select name="kategorie">%s</select></div>
|
|
<div class="formrow"><label>Inhalt</label><textarea name="inhalt" style="min-height:220px" required>%s</textarea></div>
|
|
<div class="formrow"><label>Tags (kommagetrennt)</label><input name="tags" value="%s"></div>
|
|
<button class="btn" type="submit">Speichern</button>
|
|
</form></div>""" % (err_html, html.escape(titel), kat_opts, html.escape(inhalt), html.escape(tags))
|
|
return _shell("/wissen", "Neuer Wissensartikel", body)
|
|
|
|
|
|
@app.route("/wissen/<int:article_id>")
|
|
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('<span class="pill agent" style="margin-right:4px">%s</span>' % html.escape(t.strip())
|
|
for t in (a["tags"] or "").split(",") if t.strip())
|
|
body = """<h1 class="page-title">%s</h1>
|
|
<div class="sz" style="margin-bottom:14px">%s · %s · aktualisiert %s</div>
|
|
<div class="panel" style="white-space:pre-wrap">%s</div>
|
|
<div style="margin-bottom:14px">%s</div>
|
|
<a class="btn ghost" href="/wissen/%s/edit">Bearbeiten</a>
|
|
<a class="btn ghost" href="/wissen">Zurueck zur Uebersicht</a>""" % (
|
|
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/<int:article_id>/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('<option value="%s" %s>%s</option>' % (k, "selected" if k == a["kategorie"] else "", k)
|
|
for k in KB_KATEGORIEN)
|
|
err_html = "<p class='err'>%s</p>" % html.escape(error) if error else ""
|
|
body = """<h1 class="page-title">Artikel bearbeiten</h1>
|
|
<div class="panel">%s<form method="post">
|
|
<div class="formrow"><label>Titel</label><input name="titel" value="%s" required></div>
|
|
<div class="formrow"><label>Kategorie</label><select name="kategorie">%s</select></div>
|
|
<div class="formrow"><label>Inhalt</label><textarea name="inhalt" style="min-height:220px" required>%s</textarea></div>
|
|
<div class="formrow"><label>Tags (kommagetrennt)</label><input name="tags" value="%s"></div>
|
|
<button class="btn" type="submit">Speichern</button>
|
|
<form method="post" action="/wissen/%s/delete" style="display:inline" onsubmit="return confirm(\'Artikel wirklich loeschen?\')">
|
|
<button class="btn ghost" type="submit" style="margin-left:8px">Loeschen</button>
|
|
</form>
|
|
</form></div>""" % (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/<int:article_id>/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 '<span class="pill %s">%s</span>' % (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 = '<a href="/assets" class="%s">Alle</a>' % ("active" if not ci_typ else "")
|
|
tabs += "".join('<a href="/assets?typ=%s" class="%s">%s</a>' % (t, "active" if ci_typ == t else "", t) for t in CI_TYPES)
|
|
rows = []
|
|
for c in cis:
|
|
rows.append("""<tr class="row" onclick="location.href='/assets/%s'">
|
|
<td><b>%s</b></td><td>%s</td><td>%s</td><td class="sz">%s</td></tr>""" % (
|
|
c["id"], html.escape(c["name"]), html.escape(c["ci_typ"]), _ci_status_pill(c["status"]),
|
|
html.escape(c["beschreibung"] or "")))
|
|
table = """<table class="tickets"><thead><tr>
|
|
<th>Name</th><th>Typ</th><th>Status</th><th>Beschreibung</th>
|
|
</tr></thead><tbody>%s</tbody></table>""" % ("".join(rows) or "<tr><td colspan='4' class='sz'>Keine Configuration Items.</td></tr>")
|
|
return ('<h1 class="page-title">CMDB -- Configuration Items</h1>'
|
|
'<div class="tabs">%s</div>%s'
|
|
'<div style="margin-top:14px"><a class="btn" href="/assets/new">+ Neues CI</a></div>' % (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('<option value="%s" %s>%s</option>' % (t, "selected" if t == ci_typ else "", t) for t in CI_TYPES)
|
|
status_opts = "".join('<option value="%s" %s>%s</option>' % (s, "selected" if s == status else "", s) for s in CI_STATUS)
|
|
err_html = "<p class='err'>%s</p>" % html.escape(error) if error else ""
|
|
body = """<h1 class="page-title">Neues Configuration Item</h1>
|
|
<div class="panel">%s<form method="post">
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Name</label><input name="name" value="%s" required></div>
|
|
<div class="formrow"><label>Typ</label><select name="ci_typ">%s</select></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Status</label><select name="status">%s</select></div>
|
|
<div class="formrow"></div>
|
|
</div>
|
|
<div class="formrow"><label>Beschreibung</label><textarea name="beschreibung">%s</textarea></div>
|
|
<div class="formrow"><label>Attribute (ein "Schluessel: Wert" je Zeile, z. B. IP: 10.0.0.5)</label>
|
|
<textarea name="attribute" style="min-height:100px" placeholder="IP: 10.0.0.5 Hersteller: Dell Vertragsnummer: V-2026-001">%s</textarea></div>
|
|
<button class="btn" type="submit">Anlegen</button>
|
|
</form></div>""" % (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/<int:ci_id>", 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('<option value="%s" %s>%s</option>' % (t, "selected" if t == c["ci_typ"] else "", t) for t in CI_TYPES)
|
|
status_opts = "".join('<option value="%s" %s>%s</option>' % (s, "selected" if s == c["status"] else "", s) for s in CI_STATUS)
|
|
err_html = "<p class='err'>%s</p>" % 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('<option value="%s">%s (%s)</option>' % (x["id"], html.escape(x["name"]), x["ci_typ"]) for x in other_cis)
|
|
rel_typ_opts = "".join('<option value="%s">%s</option>' % (t, t) for t in REL_TYPES)
|
|
rel_out_rows = "".join(
|
|
"<div class='tl-item'>%s <b>%s</b> <form method='post' action='/assets/%s/relationships/%s/delete' style='display:inline'>"
|
|
"<button class='btn ghost' type='submit' style='margin-left:6px;padding:2px 8px;font-size:11px'>x</button></form></div>" % (
|
|
html.escape(r["beziehungs_typ"]), html.escape(r["to_name"]), ci_id, r["id"])
|
|
for r in c["rel_out"]
|
|
) or "<p class='sz'>Keine ausgehenden Beziehungen.</p>"
|
|
rel_in_rows = "".join(
|
|
"<div class='tl-item'>%s <b>%s</b> <span class='sz'>(eingehend)</span></div>" % (
|
|
html.escape(r["beziehungs_typ"]), html.escape(r["from_name"]))
|
|
for r in c["rel_in"]
|
|
) or "<p class='sz'>Keine eingehenden Beziehungen.</p>"
|
|
|
|
body = """<h1 class="page-title">%s</h1>
|
|
<div class="panel">%s<form method="post">
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Name</label><input name="name" value="%s" required></div>
|
|
<div class="formrow"><label>Typ</label><select name="ci_typ">%s</select></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Status</label><select name="status">%s</select></div>
|
|
<div class="formrow"></div>
|
|
</div>
|
|
<div class="formrow"><label>Beschreibung</label><textarea name="beschreibung">%s</textarea></div>
|
|
<div class="formrow"><label>Attribute (ein "Schluessel: Wert" je Zeile)</label>
|
|
<textarea name="attribute" style="min-height:100px">%s</textarea></div>
|
|
<button class="btn" type="submit">Speichern</button>
|
|
</form></div>
|
|
<h2 class="section-title">Beziehungen</h2>
|
|
<div class="panel">
|
|
<h3 style="margin-top:0">Ausgehend</h3>%s
|
|
<h3>Eingehend</h3>%s
|
|
<h3>Neue Beziehung</h3>
|
|
<form method="post" action="/assets/%s/relationships">
|
|
<div class="grid-2">
|
|
<div class="formrow"><label>Beziehungstyp</label><select name="beziehungs_typ">%s</select></div>
|
|
<div class="formrow"><label>Ziel-CI</label><select name="to_ci_id">%s</select></div>
|
|
</div>
|
|
<button class="btn ghost" type="submit">Hinzufuegen</button>
|
|
</form>
|
|
</div>""" % (
|
|
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/<int:ci_id>/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/<int:ci_id>/relationships/<int:rel_id>/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)
|