Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2989a6e2f | ||
|
|
cee5311b1d |
@@ -1 +1 @@
|
||||
2
|
||||
3
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.2.1
|
||||
|
||||
+497
-56
@@ -320,6 +320,7 @@ DEFAULT_GROUP_PERMISSIONS = [
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
|
||||
{"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"},
|
||||
{"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"},
|
||||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||||
@@ -343,6 +344,8 @@ NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
|
||||
def _nav_key_visible(key, user):
|
||||
if key == "index":
|
||||
return True
|
||||
if key == "tickets":
|
||||
return user.has_permission("licenses.view")
|
||||
if key == "customers":
|
||||
return user.can_manage_customers
|
||||
if key == "users":
|
||||
@@ -577,7 +580,14 @@ class User(UserMixin):
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
# timeout=15 statt Python-Default 5s: reine Verteidigungslinie gegen
|
||||
# kurze, legitime Ueberschneidungen zweier Schreibzugriffe (SQLite
|
||||
# erlaubt nur EINEN Schreiber gleichzeitig) -- ersetzt NICHT die
|
||||
# eigentliche Regel, niemals eine zweite Verbindung waehrend einer noch
|
||||
# offenen Schreibtransaktion auf derselben zu committen (siehe
|
||||
# _process_activate für ein Beispiel, wo genau das den Fehler
|
||||
# "database is locked" ausgeloest hat).
|
||||
conn = sqlite3.connect(DB_PATH, timeout=15)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
@@ -677,10 +687,41 @@ def _ensure_schema():
|
||||
name TEXT NOT NULL,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
contact_person TEXT,
|
||||
street TEXT,
|
||||
house_number TEXT,
|
||||
postal_code TEXT,
|
||||
city TEXT,
|
||||
portal_token TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
customer_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_customers)").fetchall()}
|
||||
# contact_person/street/house_number/postal_code/city/portal_token kamen
|
||||
# nachtraeglich dazu (vorher nur Name+E-Mail+Telefon+Notizen) -- siehe
|
||||
# Kachel-Dashboard/Kundenportal weiter unten, die eine vollstaendige
|
||||
# Anschrift bzw. je Kunde einen eigenen Magic-Link-Token brauchen.
|
||||
for _col, _ddl in (
|
||||
("contact_person", "ALTER TABLE license_customers ADD COLUMN contact_person TEXT"),
|
||||
("street", "ALTER TABLE license_customers ADD COLUMN street TEXT"),
|
||||
("house_number", "ALTER TABLE license_customers ADD COLUMN house_number TEXT"),
|
||||
("postal_code", "ALTER TABLE license_customers ADD COLUMN postal_code TEXT"),
|
||||
("city", "ALTER TABLE license_customers ADD COLUMN city TEXT"),
|
||||
("portal_token", "ALTER TABLE license_customers ADD COLUMN portal_token TEXT"),
|
||||
):
|
||||
if _col not in customer_cols:
|
||||
conn.execute(_ddl)
|
||||
# Jeder Kunde braucht einen eigenen, dauerhaften Portal-Token fuer den
|
||||
# kundenweiten Magic-Link (Kundenportal-Login ohne Passwort, siehe
|
||||
# customer_portal()) -- bestehende Kunden (vor Einfuehrung dieses
|
||||
# Felds) bekommen hier rueckwirkend einen zugewiesen, statt bis zur
|
||||
# naechsten manuellen Bearbeitung ohne Portal-Zugang dazustehen.
|
||||
for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall():
|
||||
conn.execute(
|
||||
"UPDATE license_customers SET portal_token=? WHERE id=?",
|
||||
(secrets.token_urlsafe(24), _row["id"]),
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -694,6 +735,7 @@ def _ensure_schema():
|
||||
license_file_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'issued',
|
||||
fingerprint TEXT,
|
||||
hostname TEXT,
|
||||
activated_at TEXT,
|
||||
deactivated_at TEXT,
|
||||
last_heartbeat_at TEXT,
|
||||
@@ -702,12 +744,17 @@ def _ensure_schema():
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
ticket_id TEXT,
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||
)
|
||||
""")
|
||||
license_cols = {row["name"] for row in conn.execute("PRAGMA table_info(licenses)").fetchall()}
|
||||
if "ticket_id" not in license_cols:
|
||||
conn.execute("ALTER TABLE licenses ADD COLUMN ticket_id TEXT")
|
||||
if "hostname" not in license_cols:
|
||||
conn.execute("ALTER TABLE licenses ADD COLUMN hostname TEXT")
|
||||
if "lifetime" not in license_cols:
|
||||
conn.execute("ALTER TABLE licenses ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# Tickets: die dauerhafte, dem Kunden gehoerende Berechtigung "eine
|
||||
# Lizenz mit diesen Eckdaten (Typ/Module/Laufzeit) zu halten" --
|
||||
@@ -723,12 +770,57 @@ def _ensure_schema():
|
||||
type TEXT NOT NULL,
|
||||
modules TEXT NOT NULL DEFAULT '[]',
|
||||
valid_days INTEGER NOT NULL,
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||
)
|
||||
""")
|
||||
ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()}
|
||||
if "lifetime" not in ticket_cols:
|
||||
conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne
|
||||
# dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein
|
||||
# Doppelklick auf den Self-Service-"Lizenz erstellen"-Button, zwei
|
||||
# offene Tabs) beide den vorherigen _ticket_has_open_license()-Check
|
||||
# noch als "frei" sehen, bevor die jeweils andere ihre INSERT committet
|
||||
# -- ein klassisches Time-of-check/time-of-use-Problem, das reine
|
||||
# Python-Logik (_ticket_has_open_license) allein nicht zuverlaessig
|
||||
# verhindern kann. Der partielle Unique-Index erzwingt "hoechstens eine
|
||||
# offene (issued/active) Lizenz pro Ticket" direkt auf DB-Ebene; die
|
||||
# zweite, verlierende INSERT schlaegt mit IntegrityError fehl, was
|
||||
# _issue_license_from_ticket() abfaengt (siehe dort).
|
||||
# Bewusst defensiv: sollte eine bestehende Installation (aus welchem
|
||||
# Grund auch immer) bereits Bestandsdaten haben, die diese Regel
|
||||
# verletzen, soll das den Dienst nicht am Start hindern -- lieber ein
|
||||
# geloggter Fehler als ein kompletter Ausfall wegen eines Datenrands,
|
||||
# den diese Migration selbst gerade erst einfuehrt.
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket "
|
||||
"ON licenses(ticket_id) WHERE status IN ('issued','active')"
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
app.logger.error(
|
||||
"Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein "
|
||||
"Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)."
|
||||
)
|
||||
# Analoger Backstop fuer den Kundenportal-Magic-Link: portal_token
|
||||
# autorisiert komplett ohne Login (siehe customer_portal()), ein
|
||||
# doppelt vergebener oder NULL-Token wuerde sonst stillschweigend die
|
||||
# falsche Kundenzeile zurueckgeben bzw. den Kunden dauerhaft aussperren.
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token "
|
||||
"ON license_customers(portal_token) WHERE portal_token IS NOT NULL AND portal_token != ''"
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
app.logger.error(
|
||||
"Konnte idx_customers_portal_token nicht anlegen -- vermutlich haben zwei Kunden denselben "
|
||||
"portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)."
|
||||
)
|
||||
|
||||
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
|
||||
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
|
||||
@@ -829,20 +921,31 @@ def _build_user(row):
|
||||
|
||||
|
||||
def _append_changes_log(ts, who, action, target, details):
|
||||
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
|
||||
logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle
|
||||
bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
|
||||
Logs → Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt
|
||||
dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
|
||||
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
|
||||
nicht gefährden — daher bewusst best-effort mit breitem except."""
|
||||
try:
|
||||
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in zwei eigene, per
|
||||
logrotate rotierte Dateien -- die audit_log-Tabelle bleibt die Quelle
|
||||
für die durchsuchbare/sortierbare Seite unter Logs → Auditlog, wächst
|
||||
aber unbegrenzt weiter.
|
||||
|
||||
Anders als bei TESM selbst (wo live.log kontinuierlich von poe.sh mit
|
||||
Geräte-Erreichbarkeit gefüllt wird) gibt es hier keinen Hintergrund-
|
||||
prozess, der live.log von sich aus beschreibt -- ohne diese Zeile blieb
|
||||
die Datei auf einem frischen Lizenzserver schlicht leer/nicht
|
||||
vorhanden, wodurch Logs → Live dauerhaft "Keine Logfiles gefunden"
|
||||
zeigte. Jede Ticket-/Lizenzaktion (Ausstellung, Aktivierung, Widerruf,
|
||||
Heartbeat-Fehler, ...) landet daher jetzt sowohl in changes.log
|
||||
(unverändert) als auch in live.log, damit Live sowie die davon
|
||||
abgeleitete Verlauf-Seite (rotierte live.log.N-Stände) tatsächlich
|
||||
Inhalt haben. Ein Schreibfehler hier darf den eigentlichen, DB-
|
||||
basierten Audit-Trail nicht gefährden — daher bewusst best-effort mit
|
||||
breitem except je Datei."""
|
||||
line = f"{ts} [{who}] {action}"
|
||||
if target:
|
||||
line += f" — {target}"
|
||||
if details:
|
||||
line += f": {details}"
|
||||
with open(TESM_CHANGES_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
for path in (TESM_CHANGES_LOG_PATH, TESM_LIVE_LOG_PATH):
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1856,20 +1959,47 @@ def _license_row(conn, license_id):
|
||||
|
||||
def _license_dict_for_display(row, now=None):
|
||||
"""Reichert eine DB-Zeile aus 'licenses' um abgeleitete Anzeigefelder
|
||||
an (Modul-Liste, Resttage) -- teilt sich Dashboard und Detailseite."""
|
||||
an (Modul-Liste, Resttage, Ampelfarbe) -- teilt sich Dashboard,
|
||||
Ticket-/Lizenz-Detailseite und Self-Service.
|
||||
|
||||
Lifetime-Lizenzen (row['lifetime']) haben zwar ein konkretes, weit in
|
||||
der Zukunft liegendes expires_at (siehe licensing.LIFETIME_EXPIRES_AT),
|
||||
zeigen aber bewusst weder eine Resttage-Zahl noch einen Ablaufhinweis
|
||||
an -- days_left bleibt None, is_expired bleibt False, color ist immer
|
||||
'green'."""
|
||||
now = now if now is not None else time.time()
|
||||
d = dict(row)
|
||||
try:
|
||||
d["modules_list"] = json.loads(d.get("modules") or "[]")
|
||||
except ValueError:
|
||||
d["modules_list"] = []
|
||||
is_lifetime = bool(d.get("lifetime"))
|
||||
d["is_lifetime"] = is_lifetime
|
||||
if is_lifetime:
|
||||
d["days_left"] = None
|
||||
d["is_expired"] = False
|
||||
d["color"] = "green"
|
||||
return d
|
||||
try:
|
||||
expires_ts = datetime.strptime(d["expires_at"], "%Y-%m-%dT%H:%M:%SZ").timestamp()
|
||||
d["days_left"] = int((expires_ts - now) / 86400)
|
||||
days_left = int((expires_ts - now) / 86400)
|
||||
d["days_left"] = days_left
|
||||
d["is_expired"] = expires_ts < now
|
||||
# Ampelfarbe fuer Dashboard-Kacheln und Tabellen (siehe
|
||||
# index()-Dashboard): > 90 Tage gruen, 30..90 Tage orange, < 30 Tage
|
||||
# (inkl. bereits abgelaufen) rot -- exakt die vom Nutzer
|
||||
# vorgegebenen Schwellen (90 selbst zaehlt noch als orange, erst
|
||||
# 91+ ist gruen).
|
||||
if days_left < 30:
|
||||
d["color"] = "red"
|
||||
elif days_left <= 90:
|
||||
d["color"] = "orange"
|
||||
else:
|
||||
d["color"] = "green"
|
||||
except (ValueError, TypeError):
|
||||
d["days_left"] = None
|
||||
d["is_expired"] = False
|
||||
d["color"] = "red"
|
||||
return d
|
||||
|
||||
|
||||
@@ -1904,22 +2034,28 @@ def _ticket_has_open_license(conn, ticket_id):
|
||||
return bool(current and current["status"] in ("issued", "active"))
|
||||
|
||||
|
||||
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor):
|
||||
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False):
|
||||
"""Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende
|
||||
Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich
|
||||
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket."""
|
||||
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket.
|
||||
lifetime=True macht valid_days irrelevant fuer jede REGULAERE Lizenz
|
||||
aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe
|
||||
_issue_license_from_ticket) -- der Wert wird trotzdem gespeichert,
|
||||
damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei
|
||||
0/NULL anfangen muss."""
|
||||
ticket_id = secrets.token_urlsafe(24)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, status, created_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'open', ?, ?)",
|
||||
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, actor, now),
|
||||
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, status, created_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)",
|
||||
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), actor, now),
|
||||
)
|
||||
conn.commit()
|
||||
customer = _customer_row(conn, customer_id)
|
||||
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
|
||||
log_action_system(
|
||||
"ticket.create", customer["name"] if customer else str(customer_id),
|
||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {valid_days} Tage (Akteur: {actor})",
|
||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label} (Akteur: {actor})",
|
||||
)
|
||||
return ticket_id
|
||||
|
||||
@@ -1936,11 +2072,22 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
|
||||
Trial-Konditionen."""
|
||||
ticket = _ticket_row(conn, ticket_id)
|
||||
if as_trial:
|
||||
license_type, modules, valid_days = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS
|
||||
# Trial ist immer ein zeitlich kurzer Override, unabhaengig davon
|
||||
# ob das Ticket selbst als Lifetime konfiguriert ist -- siehe
|
||||
# Modul-Docstring oben.
|
||||
license_type, modules, valid_days, lifetime = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS, False
|
||||
else:
|
||||
license_type = ticket["type"]
|
||||
modules = json.loads(ticket["modules"] or "[]")
|
||||
valid_days = ticket["valid_days"]
|
||||
# "Trial" als Ticket-GRUNDTYP + Lifetime waere eine dauerhafte,
|
||||
# nie ablaufende Lizenz mit -- da Trial kein "custom"/"enterprise"
|
||||
# ist -- null Modulen: ein unsinniger, so nie beabsichtigter
|
||||
# Zustand. Lifetime gilt daher nur fuer "echte" Lizenztypen; bei
|
||||
# Trial als Grundtyp wird es hier still ignoriert (zusaetzlich in
|
||||
# ticket_new()/ticket_edit() bereits serverseitig unterbunden,
|
||||
# diese Zeile ist die letzte Absicherung direkt vor der Ausstellung).
|
||||
lifetime = bool(ticket["lifetime"]) and license_type != "trial"
|
||||
|
||||
customer = _customer_row(conn, ticket["customer_id"])
|
||||
license_file, license_pubkey = licensing.issue_license(
|
||||
@@ -1952,22 +2099,40 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
|
||||
master_public_key_b64=MASTER_PUBLIC_KEY,
|
||||
master_endpoint=_get_master_endpoint(),
|
||||
vendor=_get_vendor_info(),
|
||||
lifetime=lifetime,
|
||||
)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, "
|
||||
"license_pubkey, license_file_json, status, role, ticket_id, created_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?)",
|
||||
"license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?, ?)",
|
||||
(
|
||||
license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)),
|
||||
license_file["issued_at"], license_file["expires_at"], license_pubkey,
|
||||
json.dumps(license_file), ticket_id, actor, now,
|
||||
json.dumps(license_file), ticket_id, int(lifetime), actor, now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
# idx_one_open_license_per_ticket (siehe _ensure_schema) hat
|
||||
# zugeschlagen: zwischen dem _ticket_has_open_license()-Check des
|
||||
# Aufrufers und diesem INSERT hat eine ZWEITE, nahezu gleichzeitige
|
||||
# Anfrage (z.B. Doppelklick auf den Self-Service-Button, zwei
|
||||
# offene Tabs) bereits eine Lizenz fuer dieses Ticket angelegt.
|
||||
# Statt zwei gleichzeitig gueltige Lizenzen aus einem Ticket zu
|
||||
# erlauben, wird dieser zweite Versuch sauber abgebrochen -- der
|
||||
# Aufrufer bekommt die inzwischen tatsaechlich vorhandene Lizenz
|
||||
# zurueck, als waere er selbst nicht der Ausstellende gewesen.
|
||||
conn.rollback()
|
||||
existing = _latest_license_for_ticket(conn, ticket_id)
|
||||
if existing:
|
||||
return _license_dict_for_display(existing)
|
||||
raise
|
||||
expiry_label = "Lifetime" if lifetime else f"bis {license_file['expires_at']}"
|
||||
log_action_system(
|
||||
"license.issue", customer["name"] if customer else "?",
|
||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, bis {license_file['expires_at']} (Ticket, Akteur: {actor})",
|
||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})",
|
||||
)
|
||||
return license_file
|
||||
|
||||
@@ -2020,10 +2185,16 @@ def _process_activate(data):
|
||||
if row["status"] == "active" and row["fingerprint"] and row["fingerprint"] != fingerprint:
|
||||
conn.close()
|
||||
return {"error": "already_bound_elsewhere"}, 409
|
||||
# hostname ist rein informativ (Dashboard-Kacheln, siehe index()) und
|
||||
# war frueher gar nicht Teil der Anfrage -- data.get(...) liefert bei
|
||||
# aelteren, noch nicht aktualisierten Clients einfach "", das UPDATE
|
||||
# ueberschreibt dann bewusst NICHT einen ggf. schon bekannten Hostnamen
|
||||
# (siehe COALESCE unten).
|
||||
hostname = data.get("hostname", "")
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"UPDATE licenses SET status='active', fingerprint=?, activated_at=? WHERE license_id=?",
|
||||
(fingerprint, now, license_id),
|
||||
"UPDATE licenses SET status='active', fingerprint=?, hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=? WHERE license_id=?",
|
||||
(fingerprint, hostname, now, license_id),
|
||||
)
|
||||
# Ein physisches System (= Fingerprint) kann immer nur GENAU eine
|
||||
# Lizenzdatei gleichzeitig halten -- wird hier eine neue aktiviert
|
||||
@@ -2038,19 +2209,33 @@ def _process_activate(data):
|
||||
"SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?",
|
||||
(fingerprint, license_id),
|
||||
).fetchall()
|
||||
# log_action_system() oeffnet INTERN eine EIGENE, zweite Verbindung und
|
||||
# committet sofort -- solange `conn` hier noch eine offene Schreib-
|
||||
# transaktion haelt (erstes UPDATE oben, noch nicht committet), wuerde
|
||||
# dieser zweite Commit-Versuch auf sich selbst warten (SQLite erlaubt
|
||||
# nur EINEN Schreiber gleichzeitig) und nach Ablauf des Busy-Timeouts
|
||||
# mit "database is locked" fehlschlagen -- live reproduziert beim
|
||||
# Aktivieren einer Lizenz, die eine andere fuer denselben Fingerprint
|
||||
# ersetzt. Deshalb werden die Log-Eintraege hier nur VORGEMERKT (reine
|
||||
# Lesezugriffe auf `conn` sind unproblematisch, nur der schreibende
|
||||
# log_action_system()-Aufruf selbst muss warten) und dann als allerletzter
|
||||
# Schritt nach `conn.commit()`/`conn.close()` unten nachgeholt.
|
||||
superseded_logs = []
|
||||
for old in superseded:
|
||||
conn.execute(
|
||||
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
|
||||
(now, old["license_id"]),
|
||||
)
|
||||
old_customer = _customer_row(conn, old["customer_id"])
|
||||
log_action_system(
|
||||
"license.superseded", old_customer["name"] if old_customer else old["license_id"],
|
||||
superseded_logs.append((
|
||||
old_customer["name"] if old_customer else old["license_id"],
|
||||
f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.",
|
||||
)
|
||||
))
|
||||
conn.commit()
|
||||
customer = _customer_row(conn, row["customer_id"])
|
||||
conn.close()
|
||||
for target, details in superseded_logs:
|
||||
log_action_system("license.superseded", target, details)
|
||||
log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}")
|
||||
return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
||||
|
||||
@@ -2109,8 +2294,12 @@ def _process_heartbeat(data):
|
||||
conn.close()
|
||||
return {"error": "not_active"}, 409
|
||||
|
||||
hostname = data.get("hostname", "")
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute("UPDATE licenses SET last_heartbeat_at=? WHERE license_id=?", (now, license_id))
|
||||
conn.execute(
|
||||
"UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) WHERE license_id=?",
|
||||
(now, hostname, license_id),
|
||||
)
|
||||
conn.commit()
|
||||
license_update = json.loads(row["license_file_json"])
|
||||
conn.close()
|
||||
@@ -2176,6 +2365,28 @@ def license_manual_code():
|
||||
return render_template("license_manual_code.html", result_code=result_code, processed_action=processed_action)
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _customer_form_fields(form):
|
||||
"""Liest die Kunden-Formularfelder einheitlich aus (Anlegen UND
|
||||
Bearbeiten nutzen dieselben Feldnamen) -- Anschrift ist bewusst in
|
||||
Einzelfelder aufgeteilt (Straße/Hausnummer/PLZ/Ort statt eines
|
||||
Freitextfelds), damit sie z.B. für Rechnungen sauber weiterverwendet
|
||||
werden kann."""
|
||||
return {
|
||||
"name": form.get("name", "").strip(),
|
||||
"contact_email": form.get("contact_email", "").strip(),
|
||||
"contact_phone": form.get("contact_phone", "").strip(),
|
||||
"contact_person": form.get("contact_person", "").strip(),
|
||||
"street": form.get("street", "").strip(),
|
||||
"house_number": form.get("house_number", "").strip(),
|
||||
"postal_code": form.get("postal_code", "").strip(),
|
||||
"city": form.get("city", "").strip(),
|
||||
"notes": form.get("notes", "").strip(),
|
||||
}
|
||||
|
||||
|
||||
@app.route("/customers", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def customers():
|
||||
@@ -2188,34 +2399,50 @@ def customers():
|
||||
if not current_user.has_permission("customers.create"):
|
||||
flash("Keine Berechtigung, Kunden anzulegen.", "danger")
|
||||
else:
|
||||
name = request.form.get("name", "").strip()
|
||||
if not name:
|
||||
flash("Name ist erforderlich.", "danger")
|
||||
f = _customer_form_fields(request.form)
|
||||
if not f["name"]:
|
||||
flash("Firma/Name ist erforderlich.", "danger")
|
||||
elif not _EMAIL_RE.match(f["contact_email"]):
|
||||
flash("Eine gültige E-Mail-Adresse ist erforderlich.", "danger")
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO license_customers (name, contact_email, contact_phone, notes, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(name, request.form.get("contact_email", "").strip(),
|
||||
request.form.get("contact_phone", "").strip(), request.form.get("notes", "").strip(),
|
||||
"INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, "
|
||||
"street, house_number, postal_code, city, portal_token, notes, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(f["name"], f["contact_email"], f["contact_phone"], f["contact_person"],
|
||||
f["street"], f["house_number"], f["postal_code"], f["city"],
|
||||
secrets.token_urlsafe(24), f["notes"],
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
conn.commit()
|
||||
log_action("customer.create", name)
|
||||
flash(f"Kunde „{name}“ angelegt.", "success")
|
||||
log_action("customer.create", f["name"])
|
||||
flash(f"Kunde „{f['name']}“ angelegt.", "success")
|
||||
elif "edit_customer" in request.form:
|
||||
if not current_user.has_permission("customers.edit"):
|
||||
flash("Keine Berechtigung, Kunden zu ändern.", "danger")
|
||||
else:
|
||||
customer_id = int(request.form["edit_customer"])
|
||||
name = request.form.get("name", "").strip()
|
||||
f = _customer_form_fields(request.form)
|
||||
# Anders als beim Anlegen wird eine LEERE E-Mail beim
|
||||
# Bearbeiten toleriert (nur ein nicht-leerer, aber
|
||||
# ungueltiger Wert wird abgelehnt) -- sonst koennte ein
|
||||
# Admin bei den Bestandskunden von vor dieser Umstellung
|
||||
# (leere contact_email, damals noch kein Pflichtfeld) nicht
|
||||
# einmal mehr Telefonnummer/Anschrift/Notizen aktualisieren,
|
||||
# ohne zuerst zwingend eine E-Mail-Adresse nachzutragen.
|
||||
if not f["name"]:
|
||||
flash("Firma/Name ist erforderlich.", "danger")
|
||||
elif f["contact_email"] and not _EMAIL_RE.match(f["contact_email"]):
|
||||
flash("Die E-Mail-Adresse ist ungültig.", "danger")
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, notes=? WHERE id=?",
|
||||
(name, request.form.get("contact_email", "").strip(),
|
||||
request.form.get("contact_phone", "").strip(), request.form.get("notes", "").strip(),
|
||||
customer_id),
|
||||
"UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, contact_person=?, "
|
||||
"street=?, house_number=?, postal_code=?, city=?, notes=? WHERE id=?",
|
||||
(f["name"], f["contact_email"], f["contact_phone"], f["contact_person"],
|
||||
f["street"], f["house_number"], f["postal_code"], f["city"], f["notes"], customer_id),
|
||||
)
|
||||
conn.commit()
|
||||
log_action("customer.update", name)
|
||||
log_action("customer.update", f["name"])
|
||||
flash("Kunde aktualisiert.", "success")
|
||||
elif "delete_customer" in request.form:
|
||||
if not current_user.has_permission("customers.edit"):
|
||||
@@ -2262,13 +2489,18 @@ def ticket_new():
|
||||
customer_id = request.form.get("customer_id", "")
|
||||
if customer_id == "__new__":
|
||||
new_customer_name = request.form.get("new_customer_name", "").strip()
|
||||
new_customer_email = request.form.get("new_customer_email", "").strip()
|
||||
if not new_customer_name:
|
||||
flash("Bitte einen Namen für den neuen Kunden angeben.", "danger")
|
||||
conn.close()
|
||||
return redirect(url_for("ticket_new"))
|
||||
if not _EMAIL_RE.match(new_customer_email):
|
||||
flash("Bitte eine gültige E-Mail-Adresse für den neuen Kunden angeben.", "danger")
|
||||
conn.close()
|
||||
return redirect(url_for("ticket_new"))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO license_customers (name, contact_email, created_at) VALUES (?, ?, ?)",
|
||||
(new_customer_name, request.form.get("new_customer_email", "").strip(),
|
||||
"INSERT INTO license_customers (name, contact_email, portal_token, created_at) VALUES (?, ?, ?, ?)",
|
||||
(new_customer_name, new_customer_email, secrets.token_urlsafe(24),
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -2293,12 +2525,17 @@ def ticket_new():
|
||||
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
|
||||
else:
|
||||
modules = []
|
||||
# Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
|
||||
# jedes Modul (Trial ist nicht "custom"/"enterprise") -- ein
|
||||
# Zustand, den niemand absichtlich will. Serverseitig unterbunden,
|
||||
# nicht nur per JS ausgegraut (siehe ticket_new.html).
|
||||
lifetime = request.form.get("lifetime") == "1" and license_type != "trial"
|
||||
try:
|
||||
valid_days = max(1, int(request.form.get("valid_days", "365")))
|
||||
except ValueError:
|
||||
valid_days = 365
|
||||
|
||||
ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username)
|
||||
ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username, lifetime=lifetime)
|
||||
conn.close()
|
||||
flash("Ticket angelegt.", "success")
|
||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||
@@ -2364,13 +2601,117 @@ def ticket_detail(ticket_id):
|
||||
modules_list=json.loads(ticket["modules"] or "[]"),
|
||||
licenses=[_license_dict_for_display(r) for r in licenses_rows],
|
||||
has_open_license=any(l["status"] in ("issued", "active") for l in licenses_rows),
|
||||
can_delete_ticket=not any(l["status"] in ("issued", "active") for l in licenses_rows),
|
||||
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
|
||||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||
master_licensed=license_active(),
|
||||
self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True),
|
||||
portal_url=url_for("customer_portal", token=customer["portal_token"], _external=True) if customer and customer["portal_token"] else None,
|
||||
can_edit=current_user.has_permission("licenses.edit"),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/tickets/<ticket_id>/edit", methods=["POST"])
|
||||
@login_required
|
||||
def ticket_edit(ticket_id):
|
||||
"""Aendert die Eckdaten eines bestehenden Tickets (Typ/Module/
|
||||
Laufzeit/Lifetime) -- WIRKT SICH NICHT auf bereits ausgestellte
|
||||
Lizenzen aus (deren Typ/Module/Ablaufdatum wurden bei Ausstellung fest
|
||||
in die jeweilige Zeile kopiert, siehe _issue_license_from_ticket),
|
||||
sondern nur auf die naechste Lizenz, die aus diesem Ticket erzeugt
|
||||
wird. Der Kunde selbst bleibt unveraendert -- eine Neuzuordnung ist
|
||||
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
|
||||
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
|
||||
erreichbar ist)."""
|
||||
if not current_user.has_permission("licenses.edit"):
|
||||
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
|
||||
return redirect(url_for("tickets_list"))
|
||||
conn = get_db_connection()
|
||||
ticket = _ticket_row(conn, ticket_id)
|
||||
if not ticket:
|
||||
conn.close()
|
||||
flash("Ticket nicht gefunden.", "danger")
|
||||
return redirect(url_for("tickets_list"))
|
||||
|
||||
license_type = request.form.get("license_type", ticket["type"])
|
||||
if license_type not in licensing.LICENSE_TYPES:
|
||||
conn.close()
|
||||
flash("Ungültiger Lizenztyp.", "danger")
|
||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||
if license_type == "enterprise":
|
||||
modules = list(licensing.ALL_MODULES)
|
||||
elif license_type == "custom":
|
||||
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
|
||||
else:
|
||||
modules = []
|
||||
# Siehe ticket_new() -- Trial + Lifetime ist kein sinnvoller Zustand.
|
||||
lifetime = request.form.get("lifetime") == "1" and license_type != "trial"
|
||||
try:
|
||||
valid_days = max(1, int(request.form.get("valid_days", ticket["valid_days"])))
|
||||
except ValueError:
|
||||
valid_days = ticket["valid_days"]
|
||||
|
||||
conn.execute(
|
||||
"UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=? WHERE id=?",
|
||||
(license_type, json.dumps(sorted(modules)), valid_days, int(lifetime), ticket_id),
|
||||
)
|
||||
conn.commit()
|
||||
customer = _customer_row(conn, ticket["customer_id"])
|
||||
conn.close()
|
||||
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
|
||||
log_action(
|
||||
"ticket.update", customer["name"] if customer else ticket_id,
|
||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}",
|
||||
)
|
||||
flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success")
|
||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@app.route("/tickets/<ticket_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def ticket_delete(ticket_id):
|
||||
"""Loescht ein Ticket vollstaendig -- nur erlaubt, wenn es aktuell
|
||||
KEINE aktive oder ausgestellte (noch nicht aktivierte) Lizenz haelt
|
||||
(sonst wuerde der Self-Service-/Admin-Zugriff auf ein gerade genutztes
|
||||
System verschwinden). Etwaige bereits widerrufene/deaktivierte
|
||||
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
|
||||
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
|
||||
if not current_user.has_permission("licenses.edit"):
|
||||
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
|
||||
return redirect(url_for("tickets_list"))
|
||||
conn = get_db_connection()
|
||||
ticket = _ticket_row(conn, ticket_id)
|
||||
if not ticket:
|
||||
conn.close()
|
||||
flash("Ticket nicht gefunden.", "danger")
|
||||
return redirect(url_for("tickets_list"))
|
||||
if _ticket_has_open_license(conn, ticket_id):
|
||||
conn.close()
|
||||
flash("Ticket hat noch eine aktive oder ausgestellte Lizenz -- erst widerrufen, dann löschen.", "danger")
|
||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||
customer = _customer_row(conn, ticket["customer_id"])
|
||||
# Bewusst in Kauf genommenes Restrisiko: zwischen dem Check oben und
|
||||
# dem DELETE hier koennte -- nur ueber den separaten Self-Service-/
|
||||
# Portal-Link des Kunden, nicht ueber diese Admin-Oberflaeche selbst --
|
||||
# in einem extrem engen Zeitfenster eine neue Lizenz fuer dieses Ticket
|
||||
# entstehen, die dann mitgeloescht wuerde. Ein vollstaendiger Schutz
|
||||
# bräuchte eine explizite BEGIN IMMEDIATE-Transaktion um beide
|
||||
# Operationen; angesichts des sehr seltenen Zusammentreffens (Admin
|
||||
# loescht genau in dem Moment, in dem der Kunde unabhaengig davon
|
||||
# eine neue Lizenz erstellt) und des insgesamt seltenen Admin-Traffics
|
||||
# dieser internen Anwendung wird das hier nicht weiter verhaertet --
|
||||
# die deutlich wahrscheinlichere Doppel-Ausstellung (Doppelklick auf
|
||||
# denselben Button) ist bereits ueber idx_one_open_license_per_ticket
|
||||
# auf DB-Ebene ausgeschlossen (siehe _ensure_schema).
|
||||
conn.execute("DELETE FROM licenses WHERE ticket_id=?", (ticket_id,))
|
||||
conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_action("ticket.delete", customer["name"] if customer else ticket_id)
|
||||
flash("Ticket gelöscht.", "success")
|
||||
return redirect(url_for("tickets_list"))
|
||||
|
||||
|
||||
@app.route("/licenses/<license_id>")
|
||||
@login_required
|
||||
def license_detail(license_id):
|
||||
@@ -2510,12 +2851,16 @@ def license_send_email(license_id):
|
||||
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, "
|
||||
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n"
|
||||
)
|
||||
if customer["portal_token"]:
|
||||
portal_url = url_for("customer_portal", token=customer["portal_token"], _external=True)
|
||||
self_service_note += f"\nEine Übersicht all Ihrer Tickets/Lizenzen finden Sie hier:\n{portal_url}\n"
|
||||
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
|
||||
try:
|
||||
_graph_send_mail(
|
||||
customer["contact_email"],
|
||||
f"Ihre TESM-Lizenz ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})",
|
||||
f"Anbei Ihre Lizenzdatei. Bitte auf der Zielinstanz unter Einstellungen → Lizenz hochladen und aktivieren.\n\n"
|
||||
f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {row['expires_at']}\n"
|
||||
f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {valid_until}\n"
|
||||
f"{self_service_note}",
|
||||
attachment_bytes=row["license_file_json"].encode("utf-8"),
|
||||
attachment_name=f"license-{license_id}.json",
|
||||
@@ -2603,6 +2948,7 @@ def self_service(ticket_id):
|
||||
license=_license_dict_for_display(current) if current else None,
|
||||
can_issue_new=can_issue_new,
|
||||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||
portal_url=url_for("customer_portal", token=customer["portal_token"]) if customer and customer["portal_token"] else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2668,6 +3014,58 @@ def self_service_issue(ticket_id):
|
||||
return redirect(url_for("self_service", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@app.route("/portal/<token>", methods=["GET", "POST"])
|
||||
def customer_portal(token):
|
||||
"""Kundenweites Self-Service-Portal -- anders als /self-service/<ticket_id>
|
||||
(ein Magic-Link PRO TICKET) ist dieser Link PRO KUNDE: zeigt alle
|
||||
eigenen Tickets samt aktueller Lizenz auf einen Blick und erlaubt dem
|
||||
Kunden, seine eigenen Kontakt-/Anschriftdaten selbst zu pflegen.
|
||||
Bewusst NICHT bearbeitbar: Firmenname sowie die kommerziellen
|
||||
Ticket-Eckdaten (Typ/Module/Laufzeit) -- das bleibt Sache des Anbieters.
|
||||
Fuer die eigentlichen Lizenzaktionen (herunterladen/widerrufen/neu
|
||||
erstellen) verlinkt jede Ticket-Zeile auf deren bestehende
|
||||
/self-service/<ticket_id>-Seite, statt die Logik hier zu duplizieren.
|
||||
Kein Login noetig -- Autorisierung ausschliesslich durch Besitz des
|
||||
unerratbaren Tokens (siehe license_customers.portal_token)."""
|
||||
conn = get_db_connection()
|
||||
customer = conn.execute("SELECT * FROM license_customers WHERE portal_token=?", (token,)).fetchone()
|
||||
if not customer:
|
||||
conn.close()
|
||||
abort(404)
|
||||
|
||||
if request.method == "POST":
|
||||
contact_email = request.form.get("contact_email", "").strip()
|
||||
if not _EMAIL_RE.match(contact_email):
|
||||
flash("Bitte eine gültige E-Mail-Adresse angeben.", "danger")
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE license_customers SET contact_person=?, contact_phone=?, contact_email=?, "
|
||||
"street=?, house_number=?, postal_code=?, city=? WHERE id=?",
|
||||
(request.form.get("contact_person", "").strip(), request.form.get("contact_phone", "").strip(),
|
||||
contact_email, request.form.get("street", "").strip(), request.form.get("house_number", "").strip(),
|
||||
request.form.get("postal_code", "").strip(), request.form.get("city", "").strip(), customer["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert")
|
||||
flash("Ihre Daten wurden aktualisiert.", "success")
|
||||
conn.close()
|
||||
return redirect(url_for("customer_portal", token=token))
|
||||
|
||||
now = time.time()
|
||||
tickets_view = []
|
||||
for t in _tickets_for_customer(conn, customer["id"]):
|
||||
d = dict(t)
|
||||
d["modules_list"] = json.loads(d.get("modules") or "[]")
|
||||
current = _latest_license_for_ticket(conn, t["id"])
|
||||
d["license"] = _license_dict_for_display(current, now) if current else None
|
||||
tickets_view.append(d)
|
||||
conn.close()
|
||||
return render_template(
|
||||
"customer_portal.html", customer=dict(customer), tickets=tickets_view,
|
||||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||
)
|
||||
|
||||
|
||||
def _audit_archive_loop():
|
||||
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
|
||||
die audit_log-Tabelle den Schwellenwert überschritten hat, und
|
||||
@@ -3041,14 +3439,54 @@ def inject_license_topbar():
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Kunden-/Ticket-/Lizenzübersicht -- das Dashboard des Master-
|
||||
Lizenzservers. Zeigt Tickets (die dauerhafte Berechtigung des Kunden
|
||||
auf eine Lizenz mit bestimmten Eckdaten) statt roher Lizenzzeilen,
|
||||
jeweils mit ihrer aktuellen Lizenz (falls schon erstellt). Ersetzt die
|
||||
geräte-/PoE-spezifische Kachelansicht von TESM (siehe Modul-Docstring
|
||||
oben) komplett."""
|
||||
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
|
||||
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
|
||||
ansicht auf dem TESM-Dashboard selbst, siehe _dashboard_tiles.html
|
||||
dort), gruppiert nach Ablauf-Ampel (rot < 30 Tage/abgelaufen, orange
|
||||
30-90 Tage, gruen > 90 Tage oder Lifetime). Zeigt bewusst NUR Lizenzen
|
||||
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
|
||||
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
|
||||
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
|
||||
(tickets_list())."""
|
||||
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
|
||||
return render_template("index.html", tickets=None)
|
||||
return render_template("index.html", buckets=None)
|
||||
conn = get_db_connection()
|
||||
active_rows = conn.execute("""
|
||||
SELECT licenses.*, license_customers.name AS customer_name
|
||||
FROM licenses
|
||||
JOIN license_customers ON license_customers.id = licenses.customer_id
|
||||
WHERE licenses.status = 'active'
|
||||
ORDER BY licenses.expires_at ASC
|
||||
""").fetchall()
|
||||
now = time.time()
|
||||
buckets = {"red": [], "orange": [], "green": []}
|
||||
for row in active_rows:
|
||||
d = _license_dict_for_display(row, now)
|
||||
d["customer_name"] = row["customer_name"]
|
||||
buckets[d["color"]].append(d)
|
||||
stats = {
|
||||
"red": len(buckets["red"]), "orange": len(buckets["orange"]),
|
||||
"green": len(buckets["green"]), "total": len(active_rows),
|
||||
}
|
||||
conn.close()
|
||||
return render_template(
|
||||
"index.html", buckets=buckets, stats=stats,
|
||||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/tickets")
|
||||
@login_required
|
||||
def tickets_list():
|
||||
"""Ticketverwaltung -- Anlegen/Bearbeiten/Loeschen von Tickets sowie
|
||||
Einstieg in die Lizenzausstellung/-verwaltung je Ticket. Das war bis
|
||||
v1.1.0 der Inhalt des Dashboards selbst; seit der Umstellung des
|
||||
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
|
||||
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
|
||||
Navigation erreichbar."""
|
||||
if not current_user.has_permission("licenses.view"):
|
||||
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
|
||||
return redirect(url_for("index"))
|
||||
conn = get_db_connection()
|
||||
filter_customer_id = request.args.get("customer_id", type=int)
|
||||
if filter_customer_id:
|
||||
@@ -3073,11 +3511,14 @@ def index():
|
||||
d["modules_list"] = json.loads(d.get("modules") or "[]")
|
||||
current = _latest_license_for_ticket(conn, t["id"])
|
||||
d["license"] = _license_dict_for_display(current, now) if current else None
|
||||
d["can_delete"] = not (current and current["status"] in ("issued", "active"))
|
||||
tickets_view.append(d)
|
||||
conn.close()
|
||||
return render_template(
|
||||
"index.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
|
||||
can_create=current_user.has_permission("licenses.create"),
|
||||
can_edit=current_user.has_permission("licenses.edit"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ CREATE TABLE IF NOT EXISTS license_customers (
|
||||
name TEXT NOT NULL,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
contact_person TEXT,
|
||||
street TEXT,
|
||||
house_number TEXT,
|
||||
postal_code TEXT,
|
||||
city TEXT,
|
||||
portal_token TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -133,6 +139,7 @@ CREATE TABLE IF NOT EXISTS licenses (
|
||||
license_file_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'issued',
|
||||
fingerprint TEXT,
|
||||
hostname TEXT,
|
||||
activated_at TEXT,
|
||||
deactivated_at TEXT,
|
||||
last_heartbeat_at TEXT,
|
||||
@@ -141,6 +148,7 @@ CREATE TABLE IF NOT EXISTS licenses (
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
ticket_id TEXT,
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||
);
|
||||
""")
|
||||
@@ -152,6 +160,7 @@ CREATE TABLE IF NOT EXISTS license_tickets (
|
||||
type TEXT NOT NULL,
|
||||
modules TEXT NOT NULL DEFAULT '[]',
|
||||
valid_days INTEGER NOT NULL,
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
@@ -103,14 +103,30 @@ def _parse_iso(s: str) -> float:
|
||||
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||
|
||||
|
||||
# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in
|
||||
# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer
|
||||
# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/
|
||||
# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne
|
||||
# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein
|
||||
# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime"
|
||||
# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert
|
||||
# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in
|
||||
# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae-
|
||||
# sentation davon.
|
||||
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
|
||||
|
||||
|
||||
# ================================================================ Lizenz ==
|
||||
|
||||
def issue_license(*, customer, license_type, modules, valid_days,
|
||||
master_private_key_b64, master_public_key_b64,
|
||||
master_endpoint, vendor, license_id=None, now=None):
|
||||
master_endpoint, vendor, license_id=None, now=None, lifetime=False):
|
||||
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
||||
frischem Pro-Lizenz-Schlüsselpaar.
|
||||
|
||||
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
|
||||
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
|
||||
|
||||
Rückgabe: (license_file, license_pubkey) -- license_file ist die
|
||||
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
|
||||
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
|
||||
@@ -130,7 +146,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
|
||||
"type": license_type,
|
||||
"modules": sorted(modules),
|
||||
"issued_at": _iso(now),
|
||||
"expires_at": _iso(now + valid_days * 86400),
|
||||
"expires_at": LIFETIME_EXPIRES_AT if lifetime else _iso(now + valid_days * 86400),
|
||||
"license_pubkey": license_pub,
|
||||
"master_pubkey": master_public_key_b64,
|
||||
"master_endpoint": master_endpoint,
|
||||
@@ -225,6 +241,13 @@ def build_client_request(action, license_file, nonce=None) -> dict:
|
||||
payload = {
|
||||
"license_id": license_file["license_id"],
|
||||
"fingerprint": system_fingerprint(),
|
||||
# Rein informativ fuer die Dashboard-Kacheln des Masters (siehe
|
||||
# dortiges index()) -- fliesst NICHT in irgendeine Sicherheits-
|
||||
# entscheidung ein (die bleibt allein Sache des Fingerprints), wird
|
||||
# aber wie alle anderen Felder mitsigniert, damit ein Angreifer
|
||||
# ohne den privaten Lizenzschluessel keinen falschen Hostnamen
|
||||
# unterschieben kann.
|
||||
"hostname": socket.gethostname(),
|
||||
"action": action,
|
||||
"nonce": nonce or uuid.uuid4().hex,
|
||||
"timestamp": _iso(time.time()),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mein Konto · TESM-Lizenzserver</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-bg">
|
||||
<img src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" id="login-bg-logo">
|
||||
</div>
|
||||
<div class="login-card" style="max-width:720px;">
|
||||
<h1>Mein Konto</h1>
|
||||
<div class="hint" style="text-align:center; margin-bottom:18px;">{{ customer.name }}</div>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="field" style="margin-bottom:18px;">
|
||||
{% for message in messages %}
|
||||
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card card-pad" style="margin-bottom:20px;">
|
||||
<h2 style="font-size:14px; margin-bottom:14px;">Meine Tickets</h2>
|
||||
{% if tickets %}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Typ</th><th>Module</th><th>Laufzeit</th><th>Lizenz-Status</th><th style="width:1%;"></th></tr></thead>
|
||||
{% for t in tickets %}
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
|
||||
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>
|
||||
<td>
|
||||
{% if t.license %}
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
|
||||
</span>
|
||||
{% if t.license.status == 'active' %}
|
||||
{% if t.license.is_lifetime %} · Lifetime
|
||||
{% elif t.license.is_expired %} · <span style="color:var(--danger);">abgelaufen</span>
|
||||
{% elif t.license.days_left is not none %} · {{ t.license.days_left }} Tage
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-faint">Noch keine Lizenz</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ url_for('self_service', ticket_id=t.id) }}" class="btn btn-sm btn-secondary">Verwalten →</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für Sie sind aktuell keine Tickets hinterlegt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<h2 style="font-size:14px; margin-bottom:14px;">Meine Kontaktdaten</h2>
|
||||
<form method="post">
|
||||
<div class="field"><label>Ansprechpartner</label><input type="text" name="contact_person" value="{{ customer.contact_person or '' }}"></div>
|
||||
<div class="field"><label>Telefonnummer</label><input type="text" name="contact_phone" value="{{ customer.contact_phone or '' }}"></div>
|
||||
<div class="field"><label>E-Mail-Adresse *</label><input type="email" name="contact_email" value="{{ customer.contact_email or '' }}" required></div>
|
||||
<div class="field-group">
|
||||
<div class="field"><label>Straße</label><input type="text" name="street" value="{{ customer.street or '' }}"></div>
|
||||
<div class="field"><label>Hausnummer</label><input type="text" name="house_number" value="{{ customer.house_number or '' }}"></div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<div class="field"><label>PLZ</label><input type="text" name="postal_code" value="{{ customer.postal_code or '' }}"></div>
|
||||
<div class="field"><label>Ort</label><input type="text" name="city" value="{{ customer.city or '' }}"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,17 +27,26 @@
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="customersTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="name">Kunde</th>
|
||||
<th data-sort-key="name">Firma</th>
|
||||
<th data-sort-key="address">Anschrift</th>
|
||||
<th data-sort-key="email">Kontakt</th>
|
||||
<th data-sort-key="tickets">Tickets</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
{% for c in customers %}
|
||||
<tbody data-sort-name="{{ c.name|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}">
|
||||
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}">
|
||||
<tr>
|
||||
<td class="cell-name">{{ c.name }}</td>
|
||||
<td class="text-dim">{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}</td>
|
||||
<td class="text-dim"><a href="{{ url_for('index', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
|
||||
<td class="text-dim">
|
||||
{% if c.street or c.city %}
|
||||
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">
|
||||
{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}
|
||||
{% if c.contact_person %}<br><span class="text-faint" style="font-size:11px;">{{ c.contact_person }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-dim"><a href="{{ url_for('tickets_list', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
@@ -64,7 +73,7 @@
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody data-sort-pinned>
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Kunden angelegt.</td></tr>
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Kunden angelegt.</td></tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
@@ -73,7 +82,7 @@
|
||||
|
||||
{% for c in customers %}
|
||||
<div class="modal-overlay" id="editCustomerModal{{ loop.index }}">
|
||||
<div class="modal" style="max-width:480px;">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="edit_customer" value="{{ c.id }}">
|
||||
<div class="modal-header">
|
||||
@@ -82,21 +91,51 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<label>Firma / Name</label>
|
||||
<input type="text" name="name" value="{{ c.name }}" required {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<label>Ansprechpartner</label>
|
||||
<input type="text" name="contact_person" value="{{ c.contact_person or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail{% if not c.contact_email %} <span class="text-faint" style="font-weight:400;">(fehlt noch -- bitte nachtragen)</span>{% endif %}</label>
|
||||
<input type="email" name="contact_email" value="{{ c.contact_email or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Telefon</label>
|
||||
<input type="text" name="contact_phone" value="{{ c.contact_phone or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<label>Straße</label>
|
||||
<input type="text" name="street" value="{{ c.street or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Hausnummer</label>
|
||||
<input type="text" name="house_number" value="{{ c.house_number or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<div class="field">
|
||||
<label>PLZ</label>
|
||||
<input type="text" name="postal_code" value="{{ c.postal_code or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ort</label>
|
||||
<input type="text" name="city" value="{{ c.city or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
|
||||
</div>
|
||||
{% if c.portal_token %}
|
||||
<div class="field">
|
||||
<label>Kundenportal-Link (alle Tickets)</label>
|
||||
<input type="text" class="mono" value="{{ url_for('customer_portal', token=c.portal_token, _external=True) }}" readonly onclick="this.select()">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="modal-footer">
|
||||
@@ -113,7 +152,7 @@
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-overlay" id="addCustomerModal">
|
||||
<div class="modal" style="max-width:480px;">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="new_customer" value="1">
|
||||
<div class="modal-header">
|
||||
@@ -122,17 +161,29 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<label>Firma / Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Musterfirma GmbH">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" name="contact_email">
|
||||
<label>Ansprechpartner</label>
|
||||
<input type="text" name="contact_person">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail *</label>
|
||||
<input type="email" name="contact_email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Telefon</label>
|
||||
<input type="text" name="contact_phone">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<div class="field"><label>Straße</label><input type="text" name="street"></div>
|
||||
<div class="field"><label>Hausnummer</label><input type="text" name="house_number"></div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<div class="field"><label>PLZ</label><input type="text" name="postal_code"></div>
|
||||
<div class="field"><label>Ort</label><input type="text" name="city"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<textarea name="notes" rows="3"></textarea>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Kunden-/Ticket-Übersicht</div>{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Übersicht aktiver Kundeninstanzen</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if tickets is none %}
|
||||
{% if buckets is none %}
|
||||
<div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;">
|
||||
<p class="text-faint" style="font-size:13px;">
|
||||
{% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %}
|
||||
@@ -15,98 +15,105 @@
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Tickets</h2>
|
||||
<div class="hint">Jedes Ticket ist die dauerhafte Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten -- die eigentliche Lizenzdatei darunter ist austauschbar.</div>
|
||||
<h2 style="font-size:16px;">Aktive Lizenzen</h2>
|
||||
<div class="hint">
|
||||
Rein informative Übersicht nach Restlaufzeit der Ablauffristen (grün > 90 Tage, orange 30-90 Tage, rot < 30 Tage). Tickets anlegen, bearbeiten oder Lizenzen ausstellen/widerrufen geschieht unter <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a>.
|
||||
</div>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<a href="{{ url_for('ticket_new') }}" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neues Ticket
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="ticketSearch" placeholder="Tickets durchsuchen…" oninput="filterTicketsTable()">
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Rot (< 30 Tage)</div>
|
||||
<div class="stat-value offline">{{ stats.red }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Orange (30-90 Tage)</div>
|
||||
<div class="stat-value" style="color:var(--warning);">{{ stats.orange }}</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="ticketsTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="customer">Kunde</th>
|
||||
<th data-sort-key="type">Typ</th>
|
||||
<th>Module</th>
|
||||
<th data-sort-key="ticketstatus">Ticket</th>
|
||||
<th data-sort-key="expires">Lizenz-Ablauf</th>
|
||||
<th data-sort-key="status">Lizenz-Status</th>
|
||||
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
{% for t in tickets %}
|
||||
<tbody data-sort-customer="{{ t.customer_name|lower }}" data-sort-type="{{ t.type }}"
|
||||
data-sort-ticketstatus="{{ t.status }}"
|
||||
data-sort-expires="{{ t.license.expires_at if t.license else '' }}"
|
||||
data-sort-status="{{ t.license.status if t.license else '' }}"
|
||||
data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}">
|
||||
<tr>
|
||||
<td class="cell-name">{{ t.customer_name }}</td>
|
||||
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
|
||||
<td><span class="pill {{ 'online' if t.status == 'open' else 'offline' }}">{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}</span></td>
|
||||
{% if t.license %}
|
||||
<td class="text-dim">
|
||||
{{ t.license.expires_at[:10] }}
|
||||
{% if t.license.status == 'active' and t.license.days_left is not none %}
|
||||
{% if t.license.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
|
||||
{% elif t.license.days_left <= 30 %}<span style="color:var(--warning);">({{ t.license.days_left }} Tage)</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-dim">{{ t.license.last_heartbeat_at or '—' }}</td>
|
||||
{% else %}
|
||||
<td class="text-dim">—</td>
|
||||
<td class="text-faint">Noch keine Lizenz</td>
|
||||
<td class="text-dim">—</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<a class="icon-btn" title="Ticket-Details" href="{{ url_for('ticket_detail', ticket_id=t.id) }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</a>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Grün (> 90 Tage / Lifetime)</div>
|
||||
<div class="stat-value online">{{ stats.green }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody data-sort-pinned>
|
||||
<tr class="empty-row"><td colspan="8">Noch keine Tickets angelegt.</td></tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Aktiv gesamt</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro license_tile(l) %}
|
||||
{% set ds = {'red': 'offline', 'orange': 'unbekannt', 'green': 'online'}[l.color] %}
|
||||
<div class="device-card is-readonly" data-status="{{ ds }}">
|
||||
<div class="dc-top">
|
||||
<div class="dc-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="16" r="1.5"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
</div>
|
||||
<span class="pill {{ {'red': 'offline', 'orange': 'unknown', 'green': 'online'}[l.color] }}">
|
||||
{% if l.is_lifetime %}Lifetime{% elif l.is_expired %}Abgelaufen{% else %}{{ l.days_left }} Tage{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dc-name">{{ l.customer_name }}</div>
|
||||
<div class="dc-ip">{{ l.hostname or 'Hostname unbekannt' }}</div>
|
||||
<div class="text-faint" style="font-size:11px; margin-top:2px;">{{ type_labels.get(l.type, l.type) }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro section_chevron() %}<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>{% endmacro %}
|
||||
|
||||
{% if stats.total %}
|
||||
|
||||
{% if buckets.red %}
|
||||
<div class="dash-section" data-status="red">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Rot <span class="pill offline">{{ buckets.red|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for l in buckets.red %}{{ license_tile(l) }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if buckets.orange %}
|
||||
<div class="dash-section" data-status="orange">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Orange <span class="pill unknown">{{ buckets.orange|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for l in buckets.orange %}{{ license_tile(l) }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if buckets.green %}
|
||||
<div class="dash-section" data-status="green">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Grün <span class="pill online">{{ buckets.green|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for l in buckets.green %}{{ license_tile(l) }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
Aktuell keine aktivierte Lizenz. Neue Tickets/Lizenzen unter <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a> anlegen.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterTicketsTable() {
|
||||
const el = document.getElementById("ticketSearch");
|
||||
if (!el) return;
|
||||
const q = el.value.trim().toLowerCase();
|
||||
document.querySelectorAll("#ticketsTable tbody").forEach(tbody => {
|
||||
if (tbody.hasAttribute("data-sort-pinned")) return;
|
||||
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const collapsed = new Set();
|
||||
function apply() {
|
||||
document.querySelectorAll(".dash-section").forEach(section => {
|
||||
section.classList.toggle("collapsed", collapsed.has(section.dataset.status));
|
||||
});
|
||||
}
|
||||
}
|
||||
document.querySelectorAll(".dash-section-title").forEach(title => {
|
||||
title.addEventListener("click", function () {
|
||||
const key = this.closest(".dash-section").dataset.status;
|
||||
if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key);
|
||||
apply();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% set active_page = "tickets" %}
|
||||
{% block page_title %}Lizenz{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
|
||||
|
||||
@@ -25,10 +25,17 @@
|
||||
<div class="field"><label>Ausgestellt am</label><input type="text" value="{{ license.issued_at }}" disabled></div>
|
||||
<div class="field">
|
||||
<label>Läuft ab am</label>
|
||||
{% if license.is_lifetime %}
|
||||
<input type="text" value="Lifetime (läuft nie ab)" disabled>
|
||||
{% else %}
|
||||
<input type="text" value="{{ license.expires_at }}{% if license.days_left is not none %} ({{ license.days_left }} Tage {{ 'verbleibend' if not license.is_expired else 'überschritten' }}){% endif %}" disabled>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if license.fingerprint %}
|
||||
<div class="field"><label>System-Fingerabdruck</label><input type="text" class="mono" value="{{ license.fingerprint }}" disabled></div>
|
||||
{% if license.hostname %}
|
||||
<div class="field"><label>Hostname</label><input type="text" value="{{ license.hostname }}" disabled></div>
|
||||
{% endif %}
|
||||
<div class="field"><label>Aktiviert am</label><input type="text" value="{{ license.activated_at or '' }}" disabled></div>
|
||||
{% endif %}
|
||||
{% if license.last_heartbeat_at %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Live-Log</h2>
|
||||
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
|
||||
<div class="hint">Laufende Ticket-/Lizenzaktionen (Ausstellung, Aktivierung, Widerruf, Heartbeat) dieses Lizenzservers. Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if current_user.can_view_log_history %}
|
||||
@@ -42,10 +42,9 @@
|
||||
<script>
|
||||
function colorizeLine(line) {
|
||||
let cls = "";
|
||||
if (line.includes(" ist erreichbar!")) cls = "online";
|
||||
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
||||
if (line.includes(".issue") || line.includes(".activated") || line.includes(".create")) cls = "online";
|
||||
else if (line.includes(".revoke") || line.includes(".delete") || line.includes(".block") || line.includes("fehlgeschlagen")) cls = "offline";
|
||||
else if (line.startsWith("----")) cls = "sep";
|
||||
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
|
||||
const span = document.createElement("span");
|
||||
span.className = "log-line" + (cls ? " " + cls : "");
|
||||
span.textContent = line;
|
||||
@@ -53,8 +52,10 @@ function colorizeLine(line) {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ global_check_interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
// Anders als bei TESM gibt es hier kein konfigurierbares Prüfintervall
|
||||
// (kein Geräte-Polling) -- ein fester, kurzer Wert reicht für die paar
|
||||
// Ticket-/Lizenzaktionen pro Tag völlig aus.
|
||||
const intervalMilliseconds = 20000;
|
||||
|
||||
function renderLog(text, logName, totalLines) {
|
||||
const box = document.getElementById("log-box");
|
||||
@@ -81,7 +82,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||
document.addEventListener("poe:check-triggered", fetchLog);
|
||||
fetchLog();
|
||||
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
</div>
|
||||
<div class="login-card" style="max-width:520px;">
|
||||
<h1>Meine Lizenz</h1>
|
||||
<div class="hint" style="text-align:center; margin-bottom:18px;">{{ customer.name if customer else '' }}</div>
|
||||
<div class="hint" style="text-align:center; margin-bottom:18px;">
|
||||
{{ customer.name if customer else '' }}
|
||||
{% if portal_url %}<br><a href="{{ portal_url }}">← Alle meine Tickets</a>{% endif %}
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
@@ -41,7 +44,7 @@
|
||||
<label>Status</label>
|
||||
<input type="text" value="{{ {'issued': 'Ausgestellt (noch nicht aktiviert)', 'active': 'Aktiv', 'deactivated': 'Deaktiviert', 'revoked': 'Widerrufen'}.get(license.status, license.status) }}" disabled>
|
||||
</div>
|
||||
<div class="field"><label>Gültig bis</label><input type="text" value="{{ license.expires_at }}" disabled></div>
|
||||
<div class="field"><label>Gültig bis</label><input type="text" value="{{ 'Lifetime (läuft nie ab)' if license.is_lifetime else license.expires_at }}" disabled></div>
|
||||
|
||||
{% if license.status in ('issued', 'active') %}
|
||||
<a href="{{ url_for('self_service_download', ticket_id=ticket.id) }}" class="btn btn-secondary btn-block" style="margin-bottom:10px;">
|
||||
@@ -65,7 +68,7 @@
|
||||
<form method="post" action="{{ url_for('self_service_issue', ticket_id=ticket.id) }}" style="margin-bottom:10px;">
|
||||
<input type="hidden" name="issue_license" value="regular">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Neue {{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ ticket.valid_days }} Tage)
|
||||
Neue {{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('self_service_issue', ticket_id=ticket.id) }}">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% set active_page = "tickets" %}
|
||||
{% block page_title %}Ticket{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
|
||||
|
||||
@@ -17,19 +17,42 @@
|
||||
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div>
|
||||
<div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div>
|
||||
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
|
||||
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ ticket.valid_days }} Tage" disabled></div>
|
||||
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
|
||||
<div class="field">
|
||||
<label>Self-Service-Link {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Admin-Login.", "Self-Service-Link") }}</label>
|
||||
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
|
||||
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
|
||||
</div>
|
||||
{% if portal_url %}
|
||||
<div class="field">
|
||||
<label>Kundenportal-Link (alle Tickets des Kunden) {{ hi.hint_icon("Zeigt dem Kunden ALLE seine Tickets samt Lizenzstatus und erlaubt ihm, seine Kontaktdaten selbst zu pflegen.", "Kundenportal-Link") }}</label>
|
||||
<input type="text" class="mono" value="{{ portal_url }}" readonly onclick="this.select()">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<form method="post" style="margin-top:14px;">
|
||||
<div class="flex gap-2" style="margin-top:14px;">
|
||||
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<form method="post" style="flex:1;">
|
||||
<input type="hidden" name="toggle_block" value="1">
|
||||
<button type="submit" class="btn {{ 'btn-danger' if ticket.status == 'open' else 'btn-secondary' }} btn-block">
|
||||
{{ 'Ticket sperren' if ticket.status == 'open' else 'Ticket entsperren' }}
|
||||
{{ 'Sperren' if ticket.status == 'open' else 'Entsperren' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if can_delete_ticket %}
|
||||
<form method="post" action="{{ url_for('ticket_delete', ticket_id=ticket.id) }}" style="margin-top:10px;"
|
||||
data-confirm="Ticket für „{{ customer.name if customer else '?' }}“ wirklich löschen? Die Lizenzhistorie dieses Tickets geht dabei verloren.">
|
||||
<button type="submit" class="btn btn-danger btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
Ticket löschen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="hint" style="margin-top:10px;">Ticket kann erst gelöscht werden, wenn keine aktive/ausgestellte Lizenz mehr besteht.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +76,7 @@
|
||||
<input type="hidden" name="issue_license" value="regular">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ ticket.valid_days }} Tage)
|
||||
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
|
||||
</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
@@ -75,16 +98,26 @@
|
||||
<tr>
|
||||
<td class="mono text-dim">{{ l.license_id[:8] }}…</td>
|
||||
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
||||
<td class="text-dim">{{ l.expires_at[:10] }}</td>
|
||||
<td class="text-dim">{{ 'Lifetime' if l.is_lifetime else l.expires_at[:10] }}</td>
|
||||
<td>
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</a>
|
||||
{% if can_edit and l.status in ('revoked', 'issued') %}
|
||||
<form method="post" action="{{ url_for('license_delete', license_id=l.license_id) }}"
|
||||
data-confirm="Lizenz wirklich löschen? Das Ticket bleibt bestehen -- eine neue Lizenz mit denselben Eckdaten kann jederzeit daraus erstellt werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -96,4 +129,78 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal-overlay" id="editTicketModal">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">
|
||||
<div class="modal-header">
|
||||
<h3>Ticket bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="hint" style="margin-bottom:14px;">
|
||||
Wirkt sich nur auf die NÄCHSTE aus diesem Ticket erstellte Lizenz aus -- bereits ausgestellte Lizenzen bleiben unverändert.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Lizenztyp</label>
|
||||
<select name="license_type" id="editLicenseType" onchange="toggleEditModuleFields()">
|
||||
{% for lt in license_types %}
|
||||
<option value="{{ lt }}" {% if lt == ticket.type %}selected{% endif %}>{{ type_labels.get(lt, lt) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="editModuleFields" class="field {% if ticket.type != 'custom' %}hidden{% endif %}">
|
||||
<label>Module</label>
|
||||
<div class="check-list">
|
||||
{% for m in all_modules %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="modules" value="{{ m }}" {% if m in modules_list %}checked{% endif %}>
|
||||
{{ module_labels.get(m, m) }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="lifetime" value="1" id="editLifetime" {% if ticket.lifetime %}checked{% endif %}
|
||||
{% if ticket.type == 'trial' %}disabled{% endif %} onchange="toggleEditLifetime()">
|
||||
Lifetime (unbegrenzte Laufzeit)
|
||||
</label>
|
||||
</div>
|
||||
<div class="field" id="editValidDaysField" {% if ticket.lifetime %}style="display:none;"{% endif %}>
|
||||
<label>Gültigkeitsdauer (Tage)</label>
|
||||
<input type="number" name="valid_days" id="editValidDays" value="{{ ticket.valid_days }}" min="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleEditModuleFields() {
|
||||
const type = document.getElementById("editLicenseType").value;
|
||||
document.getElementById("editModuleFields").classList.toggle("hidden", type !== "custom");
|
||||
const lifetimeBox = document.getElementById("editLifetime");
|
||||
lifetimeBox.disabled = (type === "trial");
|
||||
if (type === "trial" && lifetimeBox.checked) {
|
||||
lifetimeBox.checked = false;
|
||||
toggleEditLifetime();
|
||||
}
|
||||
}
|
||||
function toggleEditLifetime() {
|
||||
document.getElementById("editValidDaysField").style.display = document.getElementById("editLifetime").checked ? "none" : "";
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% set active_page = "tickets" %}
|
||||
{% block page_title %}Neues Ticket{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten anlegen</div>{% endblock %}
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
<input type="text" name="new_customer_name" id="new_customer_name" placeholder="z.B. Musterfirma GmbH">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new_customer_email">E-Mail (neuer Kunde)</label>
|
||||
<label for="new_customer_email">E-Mail (neuer Kunde) *</label>
|
||||
<input type="email" name="new_customer_email" id="new_customer_email">
|
||||
</div>
|
||||
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -56,6 +57,13 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="lifetime" value="1" id="lifetime" onchange="toggleLifetime()">
|
||||
Lifetime (unbegrenzte Laufzeit) {{ hi.hint_icon("Ignoriert die Gültigkeitsdauer für jede REGULÄRE Lizenz aus diesem Ticket -- diese läuft dann nie ab. Eine zusätzlich erzeugte Trial-Lizenz bleibt davon unberührt und läuft weiterhin nach 30 Tagen ab. Bei Lizenztyp Trial nicht wählbar, da eine dauerhafte Trial-Lizenz ohne Module keinen Sinn ergäbe.", "Lifetime") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field" id="validDaysField">
|
||||
<label for="valid_days">Gültigkeitsdauer (Tage)</label>
|
||||
<input type="number" name="valid_days" id="valid_days" value="365" min="1" required>
|
||||
<div class="field-hint">
|
||||
@@ -83,10 +91,25 @@ function toggleNewCustomerFields() {
|
||||
function toggleModuleFields() {
|
||||
const type = document.getElementById("license_type").value;
|
||||
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
|
||||
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
|
||||
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
|
||||
// in app.py), hier nur als klares UI-Signal ausgegraut.
|
||||
const lifetimeBox = document.getElementById("lifetime");
|
||||
lifetimeBox.disabled = (type === "trial");
|
||||
if (type === "trial" && lifetimeBox.checked) {
|
||||
lifetimeBox.checked = false;
|
||||
toggleLifetime();
|
||||
}
|
||||
}
|
||||
function toggleLifetime() {
|
||||
const isLifetime = document.getElementById("lifetime").checked;
|
||||
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
|
||||
document.getElementById("valid_days").required = !isLifetime;
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
toggleNewCustomerFields();
|
||||
toggleModuleFields();
|
||||
toggleLifetime();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "tickets" %}
|
||||
{% block page_title %}Tickets{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Ticketverwaltung -- Anlegen, Bearbeiten, Löschen</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Tickets</h2>
|
||||
<div class="hint">Jedes Ticket ist die dauerhafte Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten -- die eigentliche Lizenzdatei darunter ist austauschbar.</div>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<a href="{{ url_for('ticket_new') }}" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neues Ticket
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="ticketSearch" placeholder="Tickets durchsuchen…" oninput="filterTicketsTable()">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="ticketsTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="customer">Kunde</th>
|
||||
<th data-sort-key="type">Typ</th>
|
||||
<th>Module</th>
|
||||
<th data-sort-key="duration">Laufzeit</th>
|
||||
<th data-sort-key="ticketstatus">Ticket</th>
|
||||
<th data-sort-key="status">Lizenz-Status</th>
|
||||
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
{% for t in tickets %}
|
||||
<tbody data-sort-customer="{{ t.customer_name|lower }}" data-sort-type="{{ t.type }}"
|
||||
data-sort-duration="{{ 999999 if t.lifetime else t.valid_days }}"
|
||||
data-sort-ticketstatus="{{ t.status }}"
|
||||
data-sort-status="{{ t.license.status if t.license else '' }}"
|
||||
data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}">
|
||||
<tr>
|
||||
<td class="cell-name">{{ t.customer_name }}</td>
|
||||
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
|
||||
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>
|
||||
<td><span class="pill {{ 'online' if t.status == 'open' else 'offline' }}">{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}</span></td>
|
||||
{% if t.license %}
|
||||
<td>
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
|
||||
</span>
|
||||
{% if t.license.status == 'active' and not t.license.is_lifetime and t.license.days_left is not none %}
|
||||
{% if t.license.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
|
||||
{% elif t.license.color in ('red', 'orange') %}
|
||||
<span style="color:{{ 'var(--danger)' if t.license.color == 'red' else 'var(--warning)' }};">({{ t.license.days_left }} Tage)</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">{{ t.license.last_heartbeat_at or '—' }}</td>
|
||||
{% else %}
|
||||
<td class="text-faint">Noch keine Lizenz</td>
|
||||
<td class="text-dim">—</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<a class="icon-btn" title="Ticket-Details" href="{{ url_for('ticket_detail', ticket_id=t.id) }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</a>
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten" data-open-modal="editTicketModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
{% if t.can_delete %}
|
||||
<form method="post" action="{{ url_for('ticket_delete', ticket_id=t.id) }}"
|
||||
data-confirm="Ticket für „{{ t.customer_name }}“ wirklich löschen? Die Lizenzhistorie dieses Tickets geht dabei verloren.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody data-sort-pinned>
|
||||
<tr class="empty-row"><td colspan="8">Noch keine Tickets angelegt.</td></tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
{% for t in tickets %}
|
||||
<div class="modal-overlay" id="editTicketModal{{ loop.index }}">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<form method="post" action="{{ url_for('ticket_edit', ticket_id=t.id) }}">
|
||||
<div class="modal-header">
|
||||
<h3>Ticket bearbeiten -- {{ t.customer_name }}</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="hint" style="margin-bottom:14px;">
|
||||
Wirkt sich nur auf die NÄCHSTE aus diesem Ticket erstellte Lizenz aus -- bereits ausgestellte Lizenzen bleiben unverändert.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Lizenztyp</label>
|
||||
<select name="license_type" id="editLicenseType{{ loop.index }}" onchange="toggleEditModuleFields({{ loop.index }})">
|
||||
{% for lt in license_types %}
|
||||
<option value="{{ lt }}" {% if lt == t.type %}selected{% endif %}>{{ type_labels.get(lt, lt) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="editModuleFields{{ loop.index }}" class="field {% if t.type != 'custom' %}hidden{% endif %}">
|
||||
<label>Module</label>
|
||||
<div class="check-list">
|
||||
{% for m in all_modules %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="modules" value="{{ m }}" {% if m in t.modules_list %}checked{% endif %}>
|
||||
{{ module_labels.get(m, m) }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="lifetime" value="1" id="editLifetime{{ loop.index }}"
|
||||
{% if t.lifetime %}checked{% endif %} {% if t.type == 'trial' %}disabled{% endif %}
|
||||
onchange="toggleEditLifetime({{ loop.index }})">
|
||||
Lifetime (unbegrenzte Laufzeit)
|
||||
</label>
|
||||
</div>
|
||||
<div class="field" id="editValidDaysField{{ loop.index }}" {% if t.lifetime %}style="display:none;"{% endif %}>
|
||||
<label>Gültigkeitsdauer (Tage)</label>
|
||||
<input type="number" name="valid_days" id="editValidDays{{ loop.index }}" value="{{ t.valid_days }}" min="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterTicketsTable() {
|
||||
const el = document.getElementById("ticketSearch");
|
||||
if (!el) return;
|
||||
const q = el.value.trim().toLowerCase();
|
||||
document.querySelectorAll("#ticketsTable tbody").forEach(tbody => {
|
||||
if (tbody.hasAttribute("data-sort-pinned")) return;
|
||||
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
function toggleEditModuleFields(i) {
|
||||
const type = document.getElementById("editLicenseType" + i).value;
|
||||
document.getElementById("editModuleFields" + i).classList.toggle("hidden", type !== "custom");
|
||||
// Trial + Lifetime ergibt keinen Sinn (dauerhaft nie ablaufende Lizenz
|
||||
// ohne jedes Modul) -- serverseitig ohnehin unterbunden, hier nur
|
||||
// als UI-Signal ausgegraut.
|
||||
const lifetimeBox = document.getElementById("editLifetime" + i);
|
||||
lifetimeBox.disabled = (type === "trial");
|
||||
if (type === "trial" && lifetimeBox.checked) {
|
||||
lifetimeBox.checked = false;
|
||||
toggleEditLifetime(i);
|
||||
}
|
||||
}
|
||||
function toggleEditLifetime(i) {
|
||||
const checked = document.getElementById("editLifetime" + i).checked;
|
||||
document.getElementById("editValidDaysField" + i).style.display = checked ? "none" : "";
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user