diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION index 3c43790..c04c650 100644 --- a/srv/tesm-license/VERSION +++ b/srv/tesm-license/VERSION @@ -1 +1 @@ -1.2.6 +1.2.7 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py index 485a22f..8d30e2e 100644 --- a/srv/tesm-license/app.py +++ b/srv/tesm-license/app.py @@ -881,6 +881,64 @@ def _ensure_schema(): ) """) + # Sub-Lizenzserver-Reporting (siehe role="license_server" in + # licensing.py): ein Sub-Lizenzserver meldet bei jedem Heartbeat (und + # sofort nach jeder lokalen Aenderung, siehe _report_to_master_async()) + # einen vollstaendigen Schnappschuss seiner EIGENEN Kunden/Tickets/ + # Lizenzen. Bewusst DREI EIGENE Tabellen statt Wiederverwendung von + # license_customers/license_tickets/licenses -- das hier ist rein + # gemeldete, von diesem Master aus nicht editierbare Fremd-Information + # (der Master verwaltet diese Daten nicht selbst, er zeigt sie nur an), + # eine Vermischung mit selbst verwalteten Datensaetzen wuerde das + # verwischen. source_license_id verankert jede Zeile an genau der + # Lizenz (role='license_server'), die diesen Sub-Lizenzserver auf + # diesem Master identifiziert -- bei jedem neuen Bericht werden alle + # drei Tabellen fuer diese source_license_id komplett ersetzt (siehe + # _ingest_sub_report()), kein Diffing noetig, da ein voller + # Schnappschuss geschickt wird. + conn.execute(""" + CREATE TABLE IF NOT EXISTS reported_sub_customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_customer_ref TEXT NOT NULL, + name TEXT NOT NULL, + contact_email TEXT, contact_phone TEXT, contact_person TEXT, + street TEXT, house_number TEXT, postal_code TEXT, city TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE (source_license_id, remote_customer_ref) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS reported_sub_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_ticket_id TEXT NOT NULL, + remote_customer_ref TEXT NOT NULL, + type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', + valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer', + created_at TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE (source_license_id, remote_ticket_id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS reported_sub_licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_license_id TEXT NOT NULL, + remote_ticket_id TEXT, + type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', + issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0, + fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE (source_license_id, remote_license_id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)") + # Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung # des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) -- # sonst würden genau diese Lizenzen (auf einer echten Installation z.B. @@ -1962,6 +2020,56 @@ def _license_api_post(endpoint, path, payload, timeout=10): return json.loads(resp.read().decode("utf-8")) +MAX_REPORT_ROWS = 5000 + + +def _build_own_issuance_report(conn): + """Nur relevant, wenn DIESE Instanz selbst ein Sub-Lizenzserver ist + (eigene Lizenz mit role='license_server', siehe licensing.LICENSE_ROLES) + -- baut einen vollstaendigen Schnappschuss der EIGENEN, von dieser + Instanz selbst verwalteten Kunden/Tickets/Lizenzen (NICHT die eigene + Bootstrap-Lizenz dieser Instanz selbst -- die steht in license.json, + nicht in diesen Tabellen). Wird bei jedem Heartbeat an den Master + mitgeschickt (siehe _send_heartbeat()), der daraus seine + reported_sub_*-Tabellen befuellt (siehe _ingest_sub_report() auf der + Master-Seite). Ein voller Schnappschuss statt Deltas -- deutlich + einfacher korrekt zu halten (keine Delta-/Loeschverfolgung noetig), + bei den hier realistischen Datenmengen (Kunden eines einzelnen + Sub-Lizenzservers) unproblematisch.""" + customers = conn.execute( + "SELECT id, name, contact_email, contact_phone, contact_person, " + "street, house_number, postal_code, city FROM license_customers " + "ORDER BY id LIMIT ?", (MAX_REPORT_ROWS,) + ).fetchall() + tickets = conn.execute( + "SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at " + "FROM license_tickets ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,) + ).fetchall() + licenses_rows = conn.execute( + "SELECT license_id, ticket_id, type, modules, issued_at, expires_at, status, lifetime, " + "fingerprint, hostname, activated_at, last_heartbeat_at " + "FROM licenses ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,) + ).fetchall() + return { + "customers": [dict(r) for r in customers], + "tickets": [dict(r) for r in tickets], + "licenses": [dict(r) for r in licenses_rows], + } + + +def _report_to_master_async(): + """Feuert einen sofortigen Best-Effort-Heartbeat (inkl. Bericht, siehe + _build_own_issuance_report) in einem eigenen Hintergrund-Thread aus -- + aufgerufen direkt nach jeder lokalen Ticket-/Lizenz-Aenderung dieser + Instanz, damit der Master zeitnah einen aktuellen Stand sieht, statt bis + zu LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h) warten zu muessen. Laeuft + bewusst asynchron und schluckt jeden Fehler (siehe _send_heartbeat) -- + ein nicht erreichbarer Master darf eine lokale Ticket-/Lizenzaktion + niemals verzoegern oder fehlschlagen lassen.""" + if _license_state.get("status") and _license_state["status"].get("role") == "license_server": + threading.Thread(target=_send_heartbeat, daemon=True).start() + + def _send_heartbeat(): """Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren @@ -1976,7 +2084,14 @@ def _send_heartbeat(): endpoint = license_file.get("master_endpoint", "") if not endpoint: return - request_payload = licensing.build_client_request("heartbeat", license_file) + report = None + if _license_state.get("status", {}).get("role") == "license_server": + report_conn = get_db_connection() + try: + report = _build_own_issuance_report(report_conn) + finally: + report_conn.close() + request_payload = licensing.build_client_request("heartbeat", license_file, report=report) try: response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload) except Exception as exc: @@ -2214,6 +2329,7 @@ def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, "ticket.create", customer["name"] if customer else str(customer_id), f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}{role_label} (Akteur: {actor})", ) + _report_to_master_async() return ticket_id @@ -2299,6 +2415,7 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False): "license.issue", customer["name"] if customer else "?", f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})", ) + _report_to_master_async() return license_file @@ -2319,6 +2436,7 @@ def _revoke_license_row(conn, license_row, actor): "license.revoke", customer["name"] if customer else license_row["license_id"], f"Akteur: {actor}", ) + _report_to_master_async() # --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung ------------------- @@ -2434,6 +2552,64 @@ def _process_deactivate(data): return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200 +def _ingest_sub_report(conn, source_license_id, report): + """Uebernimmt einen von einem Sub-Lizenzserver per Heartbeat gemeldeten + Schnappschuss (siehe _build_own_issuance_report() auf dessen Seite) in + die reported_sub_*-Tabellen dieses Masters -- rein informativ, dieser + Master verwaltet diese Datensaetze nicht selbst. Ersetzt bei jedem + Bericht ALLE bisherigen Zeilen fuer diese source_license_id komplett + (kein Diffing) -- ein voller Schnappschuss macht z.B. beim Sub- + Lizenzserver geloeschte Tickets/Kunden automatisch korrekt verschwinden, + ohne eigene Loeschverfolgung. Aufrufer MUSS bereits geprueft haben, dass + die zugehoerige Lizenz tatsaechlich role='license_server' traegt (siehe + _process_heartbeat) -- diese Funktion vertraut dem Aufrufer.""" + if not isinstance(report, dict): + return + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute("DELETE FROM reported_sub_licenses WHERE source_license_id=?", (source_license_id,)) + conn.execute("DELETE FROM reported_sub_tickets WHERE source_license_id=?", (source_license_id,)) + conn.execute("DELETE FROM reported_sub_customers WHERE source_license_id=?", (source_license_id,)) + for c in (report.get("customers") or [])[:MAX_REPORT_ROWS]: + try: + conn.execute( + "INSERT INTO reported_sub_customers (source_license_id, remote_customer_ref, name, " + "contact_email, contact_phone, contact_person, street, house_number, postal_code, city, " + "last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (source_license_id, str(c.get("id", "")), c.get("name", "") or "?", + c.get("contact_email"), c.get("contact_phone"), c.get("contact_person"), + c.get("street"), c.get("house_number"), c.get("postal_code"), c.get("city"), now), + ) + except (sqlite3.IntegrityError, TypeError, KeyError): + continue + for t in (report.get("tickets") or [])[:MAX_REPORT_ROWS]: + try: + conn.execute( + "INSERT INTO reported_sub_tickets (source_license_id, remote_ticket_id, remote_customer_ref, " + "type, modules, valid_days, lifetime, status, role, created_at, last_reported_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (source_license_id, str(t.get("id", "")), str(t.get("customer_id", "")), + t.get("type", "standard"), t.get("modules", "[]"), t.get("valid_days"), + int(bool(t.get("lifetime"))), t.get("status", "open"), t.get("role", "customer"), + t.get("created_at"), now), + ) + except (sqlite3.IntegrityError, TypeError, KeyError): + continue + for l in (report.get("licenses") or [])[:MAX_REPORT_ROWS]: + try: + conn.execute( + "INSERT INTO reported_sub_licenses (source_license_id, remote_license_id, remote_ticket_id, " + "type, modules, issued_at, expires_at, status, lifetime, fingerprint, hostname, " + "activated_at, last_heartbeat_at, last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (source_license_id, l.get("license_id", ""), l.get("ticket_id"), + l.get("type", "standard"), l.get("modules", "[]"), l.get("issued_at"), l.get("expires_at"), + l.get("status", "issued"), int(bool(l.get("lifetime"))), l.get("fingerprint"), + l.get("hostname"), l.get("activated_at"), l.get("last_heartbeat_at"), now), + ) + except (sqlite3.IntegrityError, TypeError, KeyError): + continue + conn.commit() + + def _process_heartbeat(data): license_id = data.get("license_id") conn = get_db_connection() @@ -2466,6 +2642,14 @@ def _process_heartbeat(data): (now, hostname, license_id), ) conn.commit() + # Sub-Lizenzserver-Reporting: NUR uebernehmen, wenn die Lizenz dieses + # Absenders tatsaechlich role='license_server' traegt -- Verteidigung + # in der Tiefe, ein gewoehnlicher Kunde koennte sonst versuchen, einen + # "report"-Payload unterzuschieben, um sich als Lizenzserver + # auszugeben. row['role'] steht in der Master-DB fest (beim Ausstellen + # gesetzt, siehe _issue_license_from_ticket), nicht im Client-Request. + if row["role"] == "license_server" and data.get("report"): + _ingest_sub_report(conn, license_id, data["report"]) license_update = json.loads(row["license_file_json"]) conn.close() return licensing.build_master_response( @@ -2622,11 +2806,17 @@ def customers(): log_action("customer.delete", str(customer_id)) flash("Kunde gelöscht.", "success") conn.close() + # Deckt alle drei Sub-Aktionen mit einem Aufruf ab -- der Report + # ist ohnehin immer ein voller Schnappschuss, ein zusaetzlicher + # Versand bei einer fehlgeschlagenen Validierung sendet einfach + # den unveraenderten Stand erneut (harmlos). + _report_to_master_async() return redirect(url_for("customers")) customers_rows = conn.execute(""" SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count, - COUNT(DISTINCT licenses.id) AS license_count + COUNT(DISTINCT licenses.id) AS license_count, + MAX(CASE WHEN licenses.role='license_server' AND licenses.status='active' THEN 1 ELSE 0 END) AS is_sub_license_server FROM license_customers LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id LEFT JOIN licenses ON licenses.customer_id = license_customers.id @@ -2641,6 +2831,85 @@ def customers(): ) +@app.route("/customers/") +@login_required +def customer_detail(customer_id): + """Kunden-Detailseite -- zeigt Stammdaten sowie, falls dieser Kunde eine + aktive role='license_server'-Lizenz haelt (er also selbst ein + Sub-Lizenzserver ist), einen zusaetzlichen Abschnitt mit dessen + GEMELDETEN eigenen Kunden/Tickets/Lizenzen (siehe reported_sub_*- + Tabellen, befuellt durch _ingest_sub_report() bei jedem Heartbeat + dieses Sub-Lizenzservers). Rein informativ/read-only -- dieser Master + verwaltet die gemeldeten Datensaetze nicht selbst.""" + if not current_user.can_manage_customers: + flash("Keine Berechtigung, Kunden anzusehen.", "danger") + return redirect(url_for("index")) + conn = get_db_connection() + customer = _customer_row(conn, customer_id) + if not customer: + conn.close() + flash("Kunde nicht gefunden.", "danger") + return redirect(url_for("customers")) + + own_tickets = conn.execute( + "SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,) + ).fetchall() + now = time.time() + tickets_view = [] + for t in own_tickets: + 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) + + # Ist dieser Kunde selbst ein (aktiver) Sub-Lizenzserver? Referenz fuer + # die reported_sub_*-Zeilen ist die license_id der role='license_server'- + # Lizenz, die dieser Kunde von UNS erhalten hat -- nicht seine eigene, + # bei ihm lokal laufende Lizenz. + sub_license_row = conn.execute( + "SELECT * FROM licenses WHERE customer_id=? AND role='license_server' AND status='active' " + "ORDER BY activated_at DESC LIMIT 1", (customer_id,) + ).fetchone() + + reported_customers, reported_tickets_by_customer, reported_licenses_by_ticket = [], {}, {} + if sub_license_row: + source_license_id = sub_license_row["license_id"] + reported_customers = [ + dict(r) for r in conn.execute( + "SELECT * FROM reported_sub_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE", + (source_license_id,), + ).fetchall() + ] + for t in conn.execute( + "SELECT * FROM reported_sub_tickets WHERE source_license_id=? ORDER BY created_at DESC", + (source_license_id,), + ).fetchall(): + reported_tickets_by_customer.setdefault(t["remote_customer_ref"], []).append(dict(t)) + for l in conn.execute( + "SELECT * FROM reported_sub_licenses WHERE source_license_id=? ORDER BY issued_at DESC", + (source_license_id,), + ).fetchall(): + # _license_dict_for_display() erwartet dieselben Spaltennamen + # (expires_at/lifetime/status/modules), die reported_sub_licenses + # bewusst 1:1 uebernimmt -- liefert damit automatisch dieselbe + # Ampelfarbe/Resttage-Berechnung wie fuer eigene Lizenzen. + reported_licenses_by_ticket.setdefault(l["remote_ticket_id"], []).append( + _license_dict_for_display(l, now) + ) + + conn.close() + return render_template( + "customer_detail.html", customer=customer, tickets=tickets_view, + sub_license=dict(sub_license_row) if sub_license_row else None, + reported_customers=reported_customers, + reported_tickets_by_customer=reported_tickets_by_customer, + reported_licenses_by_ticket=reported_licenses_by_ticket, + type_labels=LICENSE_TYPE_LABELS, + can_edit=current_user.has_permission("customers.edit"), + ) + + @app.route("/tickets/new", methods=["GET", "POST"]) @login_required def ticket_new(): @@ -2871,6 +3140,7 @@ def ticket_edit(ticket_id): "ticket.update", customer["name"] if customer else ticket_id, f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}", ) + _report_to_master_async() flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success") return redirect(url_for("ticket_detail", ticket_id=ticket_id)) @@ -2916,6 +3186,7 @@ def ticket_delete(ticket_id): conn.commit() conn.close() log_action("ticket.delete", customer["name"] if customer else ticket_id) + _report_to_master_async() flash("Ticket gelöscht.", "success") return redirect(url_for("tickets_list")) @@ -3132,6 +3403,7 @@ def license_delete(license_id): conn.commit() conn.close() log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}") + _report_to_master_async() flash("Lizenz gelöscht.", "success") if ticket_id: return redirect(url_for("ticket_detail", ticket_id=ticket_id)) diff --git a/srv/tesm-license/create_db.py b/srv/tesm-license/create_db.py index d56b9f9..59a5f86 100644 --- a/srv/tesm-license/create_db.py +++ b/srv/tesm-license/create_db.py @@ -180,6 +180,49 @@ CREATE TABLE IF NOT EXISTS portal_access_tokens ( ); """) +c.execute(""" +CREATE TABLE IF NOT EXISTS reported_sub_customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_customer_ref TEXT NOT NULL, + name TEXT NOT NULL, + contact_email TEXT, contact_phone TEXT, contact_person TEXT, + street TEXT, house_number TEXT, postal_code TEXT, city TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE (source_license_id, remote_customer_ref) +); +""") +c.execute(""" +CREATE TABLE IF NOT EXISTS reported_sub_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_ticket_id TEXT NOT NULL, + remote_customer_ref TEXT NOT NULL, + type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', + valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer', + created_at TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE (source_license_id, remote_ticket_id) +); +""") +c.execute(""" +CREATE TABLE IF NOT EXISTS reported_sub_licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_license_id TEXT NOT NULL, + remote_license_id TEXT NOT NULL, + remote_ticket_id TEXT, + type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', + issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0, + fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT, + last_reported_at TEXT NOT NULL, + UNIQUE (source_license_id, remote_license_id) +); +""") +c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)") +c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)") +c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)") + c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",)) diff --git a/srv/tesm-license/licensing.py b/srv/tesm-license/licensing.py index e9f2de3..b42933f 100644 --- a/srv/tesm-license/licensing.py +++ b/srv/tesm-license/licensing.py @@ -275,11 +275,24 @@ def system_fingerprint(): # gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam # verhindert". -def build_client_request(action, license_file, nonce=None) -> dict: +def build_client_request(action, license_file, nonce=None, report=None) -> dict: """Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action: "activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es - zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.""" + zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt. + + report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht + (siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein + optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses + Sub-Lizenzservers, den der Master beim Empfang in seine + reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben. + Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht + ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine + "customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt + dann in der Payload wie bisher (keine Verhaltensänderung für alle + bestehenden Aufrufer). Wie jedes andere Feld wird auch report + mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann + keine gefälschten Berichte unterschieben.""" if action not in ("activate", "deactivate", "heartbeat"): raise ValueError(f"Unbekannte Aktion: {action!r}") payload = { @@ -296,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict: "nonce": nonce or uuid.uuid4().hex, "timestamp": _iso(time.time()), } + if report is not None: + payload["report"] = report signature = sign_payload(payload, license_file["license_privkey"]) return {**payload, "signature": signature} diff --git a/srv/tesm-license/templates/customer_detail.html b/srv/tesm-license/templates/customer_detail.html new file mode 100644 index 0000000..19cf85d --- /dev/null +++ b/srv/tesm-license/templates/customer_detail.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% set active_page = "customers" %} +{% block page_title %}Kunde{% endblock %} +{% block page_sub %}
{{ customer.name }}
{% endblock %} + +{% block content %} +{% import "_hint_icon.html" as hi %} + +
+
+

+ {{ customer.name }} + {% if sub_license %}Lizenzserver{% endif %} +

+
+ ← Alle Kunden +
+ +
+
+
+

Stammdaten

+ {% if can_edit %} + Bearbeiten + {% endif %} +
+
+
+
+
+ + +
+ {% if customer.notes %} +
+ {% endif %} +
+ +
+
+

Eigene Tickets bei uns

+ Neues Ticket +
+ {% if tickets %} +
+ + + + {% for t in tickets %} + + + + + + + {% endfor %} + +
TypRolleTicketLizenz-Status
{{ type_labels.get(t.type, t.type) }}{% if t.role == 'license_server' %}Lizenzserver{% else %}Kunde{% endif %} + + {{ 'Offen' if t.status == 'open' else 'Gesperrt' }} + + + {% if t.license %} + + {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }} + + {% else %}Noch keine Lizenz{% endif %} +
+
+ {% else %} +

Noch keine Tickets für diesen Kunden.

+ {% endif %} +
+
+ +{% if sub_license %} +
+
+
+

Von diesem Lizenzserver verwaltete Kunden

+
+ Gemeldet vom Sub-Lizenzserver selbst bei jedem Heartbeat {{ hi.hint_icon("Rein informativ -- diese Daten werden nicht von uns verwaltet, sondern vom Sub-Lizenzserver selbst bei jedem Heartbeat (alle 6h, zusaetzlich sofort nach jeder dortigen Aenderung) als vollstaendiger Schnappschuss gemeldet und hier nur angezeigt.", "Gemeldete Daten") }} + {% if sub_license.last_heartbeat_at %}— zuletzt {{ sub_license.last_heartbeat_at }}{% else %}— noch kein Heartbeat empfangen{% endif %} +
+
+
+ {% if reported_customers %} + {% for rc in reported_customers %} +
+
+
{{ rc.name }} {{ rc.contact_email or '' }}
+
+ {% set rtickets = reported_tickets_by_customer.get(rc.remote_customer_ref, []) %} + {% if rtickets %} +
+ + + + {% for rt in rtickets %} + {% set rlicenses = reported_licenses_by_ticket.get(rt.remote_ticket_id, []) %} + {% set rl = rlicenses[0] if rlicenses else none %} + + + + + {% if rl %} + + + {% else %} + + {% endif %} + + {% endfor %} + +
TypLaufzeitTicket-StatusLizenz-StatusAblauf
{{ type_labels.get(rt.type, rt.type) }}{{ 'Lifetime' if rt.lifetime else (rt.valid_days ~ ' Tage' if rt.valid_days else '—') }}{{ 'Offen' if rt.status == 'open' else 'Gesperrt' }} + + {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(rl.status, rl.status) }} + + + {% if rl.is_lifetime %}Lifetime + {% elif rl.days_left is not none %} + {{ rl.days_left }} Tage + {% else %}—{% endif %} + Noch keine Lizenz
+
+ {% else %} +

Noch keine Tickets gemeldet.

+ {% endif %} +
+ {% endfor %} + {% else %} +

Dieser Lizenzserver hat noch keine eigenen Kunden gemeldet.

+ {% endif %} +
+{% endif %} + +{% endblock %} diff --git a/srv/tesm-license/templates/customers.html b/srv/tesm-license/templates/customers.html index 728ad7b..d8b70e4 100644 --- a/srv/tesm-license/templates/customers.html +++ b/srv/tesm-license/templates/customers.html @@ -37,7 +37,10 @@ {% for c in customers %} - {{ c.name }} + + {{ c.name }} + {% if c.is_sub_license_server %}Lizenzserver{% endif %} + {% if c.street or c.city %} {{ c.street }} {{ c.house_number }}{% if c.street %}
{% endif %}{{ c.postal_code }} {{ c.city }}