From 7e39320c87da4e7ba2c7a101a0bff920cb6d5f39 Mon Sep 17 00:00:00 2001 From: alientim Date: Sun, 23 Aug 2026 11:41:51 +0200 Subject: [PATCH] Ticket-System fuer Kunden-Self-Service + Fingerprint-Auto-Supersede (v1.1.0) Konzept: Kunde kauft eine Lizenz -> Admin legt einen Kunden an (oder nutzt einen bestehenden) und weist ihm ein TICKET fuer eine Lizenz mit festen Eckdaten (Typ/Module/Laufzeit) zu -- das Ticket ist die dauerhafte, dem Kunden gehoerende Berechtigung, unabhaengig von der konkreten signierten Lizenzdatei darunter. Admin ODER Kunde (per Self-Service-Link, kein Login noetig) koennen aus einem offenen Ticket jederzeit eine Lizenz erstellen, widerrufen und erneut erstellen -- ein Kunde kann mehrere Tickets haben (z.B. mehrere Systeme). Neu: - license_tickets-Tabelle (id = unerratbares Token, customer_id, Typ/ Module/Laufzeit, status 'open'/'blocked'), licenses.ticket_id-Spalte. Rueckwirkende Migration erzeugt fuer jede bereits VOR diesem Feature direkt ausgestellte Lizenz automatisch ein passendes Ticket (sonst waeren z.B. POETESTs bereits aktivierte Produktivlizenz oder die Demo-Lizenzen im neuen, rein ticket-basierten Dashboard verschwunden) -- gegen eine echte Kopie der Live-Datenbank getestet. - /tickets/new (Ticket anlegen, admin), /tickets/ (Details, Lizenz aus Ticket erstellen -- reguraer laut Ticket-Typ ODER als Trial mit vollem Modulumfang unabhaengig vom Ticket-Typ --, Ticket sperren/ entsperren). - /self-service/: oeffentlich erreichbar (kein Login), Zugriff ueber Besitz des unerratbaren Ticket-Tokens (192 Bit Zufall) als 'Magic Link' -- Kunde kann eigene Lizenz herunterladen, widerrufen und (mit den Ticket-Eckdaten) neu erstellen. Link wird auf der Ticket-Seite angezeigt und in die 'Lizenz per E-Mail senden'-Nachricht aufgenommen. - /licenses//delete (admin): raeumt widerrufene oder nie aktivierte Lizenzen auf, OHNE das zugehoerige Ticket anzutasten -- der Kunde (oder Admin) kann sich ueber das Ticket jederzeit eine neue mit denselben Eckdaten erzeugen. - Bugfix: wird fuer einen Fingerprint eine neue Lizenz aktiviert (z.B. Trial durch Enterprise ersetzt), werden jetzt automatisch alle ANDEREN fuer denselben Fingerprint noch 'active' vermerkten Lizenzen auf 'deactivated' gesetzt -- vorher blieben beide faelschlich gleichzeitig aktiv in der Uebersicht stehen. Bewusst nach Fingerprint gefiltert (ein Kunde darf mehrere Systeme mit je eigener Lizenz betreiben). - Dashboard zeigt jetzt Tickets statt roher Lizenzzeilen (inkl. aktueller Lizenz je Ticket), Kunden-Seite zeigt Ticket-Anzahl statt Lizenz-Anzahl mit Filter-Link aufs Dashboard. SCHEMA_VERSION 1 -> 2 (neue Tabelle + Spalte, ueber install.sh- Sicherheitsnetz mit Backup+Health-Check abgesichert). Verifiziert: Migration gegen eine echte Kopie der Live-Datenbank (POETESTs aktive Produktivlizenz + 3 Demo-Lizenzen) durchlaufen lassen -- alle 8 bestehenden Lizenzen haben danach ein Ticket, keine verwaist. Anschliessend alle neuen Seiten (Dashboard, Ticket-Anlage, Ticket-Detail, Self-Service) per Playwright gegen diese migrierte Kopie auf einem isolierten Scratch-Port durchgeklickt, keine Fehler. Co-Authored-By: Claude Sonnet 5 --- srv/tesm-license/SCHEMA_VERSION | 2 +- srv/tesm-license/VERSION | 2 +- srv/tesm-license/app.py | 472 +++++++++++++++--- srv/tesm-license/create_db.py | 15 + srv/tesm-license/templates/customers.html | 10 +- srv/tesm-license/templates/index.html | 67 +-- .../templates/license_detail.html | 12 + srv/tesm-license/templates/self_service.html | 83 +++ srv/tesm-license/templates/ticket_detail.html | 99 ++++ .../{license_issue.html => ticket_new.html} | 21 +- 10 files changed, 680 insertions(+), 103 deletions(-) create mode 100644 srv/tesm-license/templates/self_service.html create mode 100644 srv/tesm-license/templates/ticket_detail.html rename srv/tesm-license/templates/{license_issue.html => ticket_new.html} (77%) diff --git a/srv/tesm-license/SCHEMA_VERSION b/srv/tesm-license/SCHEMA_VERSION index d00491f..0cfbf08 100644 --- a/srv/tesm-license/SCHEMA_VERSION +++ b/srv/tesm-license/SCHEMA_VERSION @@ -1 +1 @@ -1 +2 diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION index 7dea76e..9084fa2 100644 --- a/srv/tesm-license/VERSION +++ b/srv/tesm-license/VERSION @@ -1 +1 @@ -1.0.1 +1.1.0 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py index f85853c..158d261 100644 --- a/srv/tesm-license/app.py +++ b/srv/tesm-license/app.py @@ -701,9 +701,61 @@ def _ensure_schema(): role TEXT NOT NULL DEFAULT 'customer', created_by TEXT, created_at TEXT NOT NULL, + ticket_id TEXT, 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") + + # Tickets: die dauerhafte, dem Kunden gehoerende Berechtigung "eine + # Lizenz mit diesen Eckdaten (Typ/Module/Laufzeit) zu halten" -- + # unabhaengig von der konkreten ausgestellten Lizenzdatei darunter. + # Admin ODER Kunde (per Self-Service-Link) koennen aus einem offenen + # Ticket jederzeit eine neue Lizenz erzeugen, z.B. nachdem die vorige + # widerrufen/deaktiviert wurde oder eine nie aktivierte Lizenz vom + # Admin geloescht wurde -- das Ticket selbst bleibt davon unberuehrt. + conn.execute(""" + CREATE TABLE IF NOT EXISTS license_tickets ( + id TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL, + type TEXT NOT NULL, + modules TEXT NOT NULL DEFAULT '[]', + valid_days INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_by TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (customer_id) REFERENCES license_customers(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. + # eine bereits produktiv aktivierte Kundenlizenz!) im neuen, rein + # ticket-basierten Dashboard schlicht nicht mehr auftauchen. Eckdaten + # des Tickets werden 1:1 aus der jeweiligen Lizenz übernommen + # (Laufzeit aus issued_at/expires_at zurückgerechnet). + _migration_key_tickets = "_backfilled_tickets_for_legacy_licenses_v1" + if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets,)).fetchone(): + legacy_licenses = conn.execute("SELECT * FROM licenses WHERE ticket_id IS NULL").fetchall() + for lic in legacy_licenses: + try: + issued_ts = datetime.strptime(lic["issued_at"], "%Y-%m-%dT%H:%M:%SZ") + expires_ts = datetime.strptime(lic["expires_at"], "%Y-%m-%dT%H:%M:%SZ") + valid_days = max(1, (expires_ts - issued_ts).days) + except (ValueError, TypeError): + valid_days = 365 + ticket_id = secrets.token_urlsafe(24) + conn.execute( + "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, status, created_by, created_at) " + "VALUES (?, ?, ?, ?, ?, 'open', ?, ?)", + (ticket_id, lic["customer_id"], lic["type"], lic["modules"], valid_days, + lic["created_by"] or "system", lic["created_at"]), + ) + conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"])) + conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,)) _migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone(): @@ -1821,12 +1873,76 @@ def _license_dict_for_display(row, now=None): return d -def _issue_license_for_customer(conn, customer_id, license_type, modules, valid_days, role="customer"): - """Erzeugt + signiert eine neue Lizenz (licensing.issue_license) und - trägt sie in der Master-DB ein (Status 'issued' -- erst nach - tatsächlicher Aktivierung durch den Client wird daraus 'active', siehe - _process_activate weiter unten).""" +TRIAL_DEFAULT_DAYS = 30 + + +def _ticket_row(conn, ticket_id): + return conn.execute("SELECT * FROM license_tickets WHERE id=?", (ticket_id,)).fetchone() + + +def _tickets_for_customer(conn, customer_id): + return conn.execute( + "SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,) + ).fetchall() + + +def _latest_license_for_ticket(conn, ticket_id): + """Die 'aktuelle' Lizenz eines Tickets -- ein Ticket darf laut Konzept + immer nur eine aktive/ausstehende Lizenz gleichzeitig haben, daher + reicht die zuletzt erstellte Zeile.""" + return conn.execute( + "SELECT * FROM licenses WHERE ticket_id=? ORDER BY created_at DESC LIMIT 1", (ticket_id,) + ).fetchone() + + +def _ticket_has_open_license(conn, ticket_id): + """True, wenn das Ticket gerade eine aktive ODER ausgestellte (noch + nicht aktivierte) Lizenz haelt -- in dem Fall darf weder Admin noch + Kunde per Self-Service eine weitere daraus erzeugen, bevor die + bestehende widerrufen/deaktiviert wurde.""" + current = _latest_license_for_ticket(conn, ticket_id) + return bool(current and current["status"] in ("issued", "active")) + + +def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor): + """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.""" + 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), + ) + conn.commit() customer = _customer_row(conn, customer_id) + 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})", + ) + return ticket_id + + +def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False): + """Erzeugt + signiert eine neue Lizenz aus einem Ticket (Ed25519, siehe + licensing.issue_license) und trägt sie in der Master-DB ein -- von + Admin UND Kunde (Self-Service) gleichermaßen genutzt, daher 'actor' + als Klartext-Beschreibung statt current_user (im Self-Service-Pfad + gibt es keinen eingeloggten Benutzer). as_trial=True erzeugt + unabhängig vom eigentlichen Ticket-Typ eine zeitlich kurze + Trial-Lizenz mit vollem Modulumfang -- das Ticket selbst (Typ/Module/ + Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt + Trial-Konditionen.""" + ticket = _ticket_row(conn, ticket_id) + if as_trial: + license_type, modules, valid_days = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS + else: + license_type = ticket["type"] + modules = json.loads(ticket["modules"] or "[]") + valid_days = ticket["valid_days"] + + customer = _customer_row(conn, ticket["customer_id"]) license_file, license_pubkey = licensing.issue_license( customer={"name": customer["name"], "contact_email": customer["contact_email"] or ""}, license_type=license_type, @@ -1840,21 +1956,41 @@ def _issue_license_for_customer(conn, customer_id, license_type, modules, valid_ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( "INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, " - "license_pubkey, license_file_json, status, role, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?)", + "license_pubkey, license_file_json, status, role, ticket_id, created_by, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?)", ( - license_file["license_id"], customer_id, license_type, json.dumps(sorted(modules)), + 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), role, - current_user.username if current_user.is_authenticated else "system", - now, + json.dumps(license_file), ticket_id, actor, now, ), ) conn.commit() - log_action("license.issue", customer["name"], f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {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})", + ) return license_file +def _revoke_license_row(conn, license_row, actor): + """Widerruft eine Lizenz -- von Admin ODER Kunde (Self-Service) + nutzbar. Das zugehörige Ticket bleibt bewusst 'open': Widerrufen ist + hier eine normale Housekeeping-Aktion (Systemwechsel, Neuaufsetzen), + kein Bann -- wer den Kunden wirklich aussperren will, sperrt das + Ticket separat und explizit (siehe ticket_block()).""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + "UPDATE licenses SET status='revoked', revoked_at=? WHERE license_id=?", + (now, license_row["license_id"]), + ) + conn.commit() + customer = _customer_row(conn, license_row["customer_id"]) + log_action_system( + "license.revoke", customer["name"] if customer else license_row["license_id"], + f"Akteur: {actor}", + ) + + # --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung ------------------- # Ein Protokoll, zwei Transportwege (siehe licensing.py-Moduldocstring): # dieselben drei Funktionen bedienen sowohl die Online-JSON-API @@ -1889,6 +2025,29 @@ def _process_activate(data): "UPDATE licenses SET status='active', fingerprint=?, activated_at=? WHERE license_id=?", (fingerprint, now, license_id), ) + # Ein physisches System (= Fingerprint) kann immer nur GENAU eine + # Lizenzdatei gleichzeitig halten -- wird hier eine neue aktiviert + # (z.B. Trial durch Enterprise ersetzt), muss jede ANDERE, für + # denselben Fingerprint noch als 'active' vermerkte Lizenz automatisch + # veralten, sonst zeigt die Übersicht fälschlich zwei gleichzeitig + # aktive Lizenzen für ein- und dasselbe System. Bewusst nach + # Fingerprint gefiltert, nicht nach Kunde -- ein Kunde darf mehrere + # Systeme mit je eigener Lizenz betreiben, ohne dass die sich + # gegenseitig deaktivieren. + superseded = conn.execute( + "SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?", + (fingerprint, license_id), + ).fetchall() + 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"], + f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.", + ) conn.commit() customer = _customer_row(conn, row["customer_id"]) conn.close() @@ -2063,8 +2222,8 @@ def customers(): flash("Keine Berechtigung, Kunden zu löschen.", "danger") else: customer_id = int(request.form["delete_customer"]) - if conn.execute("SELECT 1 FROM licenses WHERE customer_id=?", (customer_id,)).fetchone(): - flash("Kunde hat noch Lizenzen -- kann nicht gelöscht werden.", "danger") + if conn.execute("SELECT 1 FROM license_tickets WHERE customer_id=?", (customer_id,)).fetchone(): + flash("Kunde hat noch Tickets -- kann nicht gelöscht werden.", "danger") else: conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,)) conn.commit() @@ -2074,9 +2233,9 @@ def customers(): return redirect(url_for("customers")) customers_rows = conn.execute(""" - SELECT license_customers.*, COUNT(licenses.id) AS license_count + SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count FROM license_customers - LEFT JOIN licenses ON licenses.customer_id = license_customers.id + LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id GROUP BY license_customers.id ORDER BY license_customers.name COLLATE NOCASE """).fetchall() @@ -2088,26 +2247,25 @@ def customers(): ) -@app.route("/licenses/issue", methods=["GET", "POST"]) +@app.route("/tickets/new", methods=["GET", "POST"]) @login_required -def license_issue(): +def ticket_new(): + """Legt ein neues Ticket an -- die Berechtigung des Kunden auf eine + Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz; + das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch + Admin ODER Kunde per Self-Service).""" if not current_user.has_permission("licenses.create"): - flash("Keine Berechtigung, Lizenzen auszustellen.", "danger") + flash("Keine Berechtigung, Tickets anzulegen.", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST": - if not license_active(): - flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger") - conn.close() - return redirect(url_for("license_issue")) - customer_id = request.form.get("customer_id", "") if customer_id == "__new__": new_customer_name = request.form.get("new_customer_name", "").strip() if not new_customer_name: flash("Bitte einen Namen für den neuen Kunden angeben.", "danger") conn.close() - return redirect(url_for("license_issue")) + 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(), @@ -2115,20 +2273,20 @@ def license_issue(): ) conn.commit() customer_id = cur.lastrowid - log_action("customer.create", new_customer_name, "beim Ausstellen einer Lizenz neu angelegt") + log_action("customer.create", new_customer_name, "beim Anlegen eines Tickets neu angelegt") else: try: customer_id = int(customer_id) except ValueError: flash("Ungültiger Kunde.", "danger") conn.close() - return redirect(url_for("license_issue")) + return redirect(url_for("ticket_new")) license_type = request.form.get("license_type", "standard") if license_type not in licensing.LICENSE_TYPES: flash("Ungültiger Lizenztyp.", "danger") conn.close() - return redirect(url_for("license_issue")) + return redirect(url_for("ticket_new")) if license_type == "enterprise": modules = list(licensing.ALL_MODULES) elif license_type == "custom": @@ -2140,21 +2298,79 @@ def license_issue(): except ValueError: valid_days = 365 - license_file = _issue_license_for_customer(conn, customer_id, license_type, modules, valid_days) + ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username) conn.close() - flash("Lizenz ausgestellt.", "success") - return redirect(url_for("license_detail", license_id=license_file["license_id"])) + flash("Ticket angelegt.", "success") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) customers_rows = conn.execute("SELECT * FROM license_customers ORDER BY name COLLATE NOCASE").fetchall() conn.close() return render_template( - "license_issue.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES, + "ticket_new.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS, - master_licensed=license_active(), preselect_customer_id=request.args.get("customer_id", type=int), ) +@app.route("/tickets/", methods=["GET", "POST"]) +@login_required +def ticket_detail(ticket_id): + if not current_user.can_manage_licenses: + flash("Keine Berechtigung.", "danger") + return redirect(url_for("index")) + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + flash("Ticket nicht gefunden.", "danger") + return redirect(url_for("index")) + + if request.method == "POST": + if not current_user.has_permission("licenses.edit"): + flash("Keine Berechtigung.", "danger") + elif "issue_license" in request.form: + if not license_active(): + flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger") + elif ticket["status"] != "open": + flash("Ticket ist gesperrt.", "danger") + elif _ticket_has_open_license(conn, ticket_id): + flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger") + else: + as_trial = request.form.get("issue_license") == "trial" + license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial) + conn.close() + flash("Lizenz ausgestellt.", "success") + return redirect(url_for("license_detail", license_id=license_file["license_id"])) + elif "toggle_block" in request.form: + new_status = "blocked" if ticket["status"] == "open" else "open" + conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id)) + conn.commit() + customer = _customer_row(conn, ticket["customer_id"]) + log_action( + "ticket.block" if new_status == "blocked" else "ticket.unblock", + customer["name"] if customer else ticket_id, + ) + flash("Ticket gesperrt." if new_status == "blocked" else "Ticket entsperrt.", "success") + conn.close() + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + + customer = _customer_row(conn, ticket["customer_id"]) + licenses_rows = conn.execute( + "SELECT * FROM licenses WHERE ticket_id=? ORDER BY created_at DESC", (ticket_id,) + ).fetchall() + conn.close() + return render_template( + "ticket_detail.html", ticket=dict(ticket), customer=customer, + 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), + 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), + can_edit=current_user.has_permission("licenses.edit"), + ) + + @app.route("/licenses/") @login_required def license_detail(license_id): @@ -2287,12 +2503,20 @@ def license_send_email(license_id): if not customer["contact_email"]: flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger") return redirect(url_for("license_detail", license_id=license_id)) + self_service_note = "" + if row["ticket_id"]: + self_service_url = url_for("self_service", ticket_id=row["ticket_id"], _external=True) + self_service_note = ( + 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" + ) 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: {row['expires_at']}\n" + f"{self_service_note}", attachment_bytes=row["license_file_json"].encode("utf-8"), attachment_name=f"license-{license_id}.json", ) @@ -2312,21 +2536,138 @@ def license_revoke(license_id): conn = get_db_connection() row = _license_row(conn, license_id) if row: - conn.execute( - "UPDATE licenses SET status='revoked', revoked_at=? WHERE license_id=?", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), license_id), - ) - conn.commit() - customer = _customer_row(conn, row["customer_id"]) - log_action("license.revoke", customer["name"] if customer else license_id, - "Wird beim nächsten Heartbeat automatisch delizensiert.") - flash("Lizenz widerrufen.", "success") + _revoke_license_row(conn, row, current_user.username) + flash("Lizenz widerrufen -- der Kunde wird beim nächsten Heartbeat automatisch delizensiert. " + "Das Ticket bleibt offen, eine neue Lizenz kann jederzeit daraus erstellt werden.", "success") else: flash("Lizenz nicht gefunden.", "danger") conn.close() return redirect(url_for("license_detail", license_id=license_id)) +@app.route("/licenses//delete", methods=["POST"]) +@login_required +def license_delete(license_id): + """Reine Aufräum-Aktion: eine widerrufene oder nie aktivierte Lizenz + aus der Übersicht entfernen. Das zugehörige Ticket bleibt bestehen -- + der Kunde kann sich darüber (Self-Service) oder der Admin (Ticket- + Seite) jederzeit eine neue mit denselben Eckdaten erzeugen.""" + if not current_user.has_permission("licenses.edit"): + flash("Keine Berechtigung.", "danger") + return redirect(url_for("index")) + conn = get_db_connection() + row = _license_row(conn, license_id) + if not row: + conn.close() + flash("Lizenz nicht gefunden.", "danger") + return redirect(url_for("index")) + if row["status"] not in ("revoked", "issued"): + conn.close() + flash("Nur widerrufene oder nicht aktivierte Lizenzen können gelöscht werden.", "danger") + return redirect(url_for("license_detail", license_id=license_id)) + ticket_id = row["ticket_id"] + customer = _customer_row(conn, row["customer_id"]) + conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,)) + conn.commit() + conn.close() + log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}") + flash("Lizenz gelöscht.", "success") + if ticket_id: + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + return redirect(url_for("index")) + + +# ============================================================ Self-Service = +# Oeffentlich erreichbar (bewusst KEIN @login_required) -- Autorisierung +# laeuft ueber den Besitz der unerratbaren Ticket-ID selbst (192 Bit +# Zufall, siehe secrets.token_urlsafe in _create_ticket), analog zu einem +# "Magic Link" in einer Bestell-/Abo-Verwaltungsmail. Erlaubt dem Kunden +# GENAU das, was in der Konzept-Vorgabe steht: eigene Lizenz herunterladen, +# widerrufen, und (mit den Ticket-Eckdaten) neu erstellen -- ohne dafuer +# ein Admin-Login zu brauchen oder auf den Anbieter warten zu muessen. + +@app.route("/self-service/") +def self_service(ticket_id): + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + abort(404) + customer = _customer_row(conn, ticket["customer_id"]) + current = _latest_license_for_ticket(conn, ticket_id) + conn.close() + can_issue_new = ticket["status"] == "open" and not (current and current["status"] in ("issued", "active")) + return render_template( + "self_service.html", ticket=dict(ticket), customer=customer, + modules_list=json.loads(ticket["modules"] or "[]"), + 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, + ) + + +@app.route("/self-service//download") +def self_service_download(ticket_id): + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + abort(404) + current = _latest_license_for_ticket(conn, ticket_id) + conn.close() + if not current: + abort(404) + content = current["license_file_json"].encode("utf-8") + return app.response_class( + content, mimetype="application/json", + headers={"Content-Disposition": f'attachment; filename="license-{current["license_id"]}.json"'}, + ) + + +@app.route("/self-service//revoke", methods=["POST"]) +def self_service_revoke(ticket_id): + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + abort(404) + current = _latest_license_for_ticket(conn, ticket_id) + if not current or current["status"] not in ("issued", "active"): + conn.close() + flash("Keine aktive Lizenz zum Widerrufen vorhanden.", "danger") + return redirect(url_for("self_service", ticket_id=ticket_id)) + _revoke_license_row(conn, current, "Kunde (Self-Service)") + conn.close() + flash("Lizenz widerrufen. Sie können jetzt eine neue erstellen.", "success") + return redirect(url_for("self_service", ticket_id=ticket_id)) + + +@app.route("/self-service//issue", methods=["POST"]) +def self_service_issue(ticket_id): + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + abort(404) + if not license_active(): + conn.close() + flash("Der Lizenzserver ist derzeit nicht verfügbar -- bitte später erneut versuchen.", "danger") + return redirect(url_for("self_service", ticket_id=ticket_id)) + if ticket["status"] != "open": + conn.close() + flash("Dieses Ticket ist gesperrt -- bitte an den Anbieter wenden.", "danger") + return redirect(url_for("self_service", ticket_id=ticket_id)) + if _ticket_has_open_license(conn, ticket_id): + conn.close() + flash("Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket.", "danger") + return redirect(url_for("self_service", ticket_id=ticket_id)) + as_trial = request.form.get("issue_license") == "trial" + _issue_license_from_ticket(conn, ticket_id, "Kunde (Self-Service)", as_trial=as_trial) + conn.close() + flash("Neue Lizenz erstellt -- bitte herunterladen und auf dem Zielsystem aktivieren.", "success") + return redirect(url_for("self_service", ticket_id=ticket_id)) + + 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 @@ -2700,23 +3041,42 @@ def inject_license_topbar(): @app.route("/") def index(): - """Kunden-/Lizenzübersicht -- das Dashboard des Master-Lizenzservers. - Ersetzt die geräte-/PoE-spezifische Kachelansicht von TESM (siehe - Modul-Docstring oben) komplett.""" + """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.""" if not current_user.is_authenticated or not current_user.has_permission("licenses.view"): - return render_template("index.html", licenses=None) + return render_template("index.html", tickets=None) conn = get_db_connection() - rows = conn.execute(""" - SELECT licenses.*, license_customers.name AS customer_name - FROM licenses - JOIN license_customers ON license_customers.id = licenses.customer_id - ORDER BY licenses.created_at DESC - """).fetchall() - conn.close() + filter_customer_id = request.args.get("customer_id", type=int) + if filter_customer_id: + ticket_rows = conn.execute(""" + SELECT license_tickets.*, license_customers.name AS customer_name + FROM license_tickets + JOIN license_customers ON license_customers.id = license_tickets.customer_id + WHERE license_tickets.customer_id = ? + ORDER BY license_tickets.created_at DESC + """, (filter_customer_id,)).fetchall() + else: + ticket_rows = conn.execute(""" + SELECT license_tickets.*, license_customers.name AS customer_name + FROM license_tickets + JOIN license_customers ON license_customers.id = license_tickets.customer_id + ORDER BY license_tickets.created_at DESC + """).fetchall() now = time.time() - licenses_view = [_license_dict_for_display(r, now) for r in rows] + tickets_view = [] + for t in ticket_rows: + 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( - "index.html", licenses=licenses_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, + "index.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, can_create=current_user.has_permission("licenses.create"), ) diff --git a/srv/tesm-license/create_db.py b/srv/tesm-license/create_db.py index 5b2a48a..809c68e 100644 --- a/srv/tesm-license/create_db.py +++ b/srv/tesm-license/create_db.py @@ -140,6 +140,21 @@ CREATE TABLE IF NOT EXISTS licenses ( role TEXT NOT NULL DEFAULT 'customer', created_by TEXT, created_at TEXT NOT NULL, + ticket_id TEXT, + FOREIGN KEY (customer_id) REFERENCES license_customers(id) +); +""") + +c.execute(""" +CREATE TABLE IF NOT EXISTS license_tickets ( + id TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL, + type TEXT NOT NULL, + modules TEXT NOT NULL DEFAULT '[]', + valid_days INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_by TEXT, + created_at TEXT NOT NULL, FOREIGN KEY (customer_id) REFERENCES license_customers(id) ); """) diff --git a/srv/tesm-license/templates/customers.html b/srv/tesm-license/templates/customers.html index b4a07fb..91567be 100644 --- a/srv/tesm-license/templates/customers.html +++ b/srv/tesm-license/templates/customers.html @@ -29,22 +29,22 @@ Kunde Kontakt - Lizenzen + Tickets Aktionen {% for c in customers %} - + {{ c.name }} {{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %} - {{ c.license_count }} + {{ c.ticket_count }}
{% if can_edit %} - {% if c.license_count == 0 %} + {% if c.ticket_count == 0 %}
+
+ {% endif %} +
+ +
+
+

Lizenz aus diesem Ticket erstellen

+
+ {% if not master_licensed %} +
+ Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe Lizenz. +
+ {% elif ticket.status != 'open' %} +

Ticket ist gesperrt — bitte zuerst entsperren.

+ {% elif has_open_license %} +

+ Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann + eine neue erstellt werden. +

+ {% elif can_edit %} +
+ + +
+
+ + +
+ {% endif %} +
+ +
+
+

Lizenzen zu diesem Ticket

+
+
+ + + {% for l in licenses %} + + + + + + + + + + {% else %} + + {% endfor %} +
Lizenz-IDTypAblaufStatusAktionen
{{ l.license_id[:8] }}…{{ type_labels.get(l.type, l.type) }}{{ l.expires_at[:10] }} + + {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }} + + + + + +
Noch keine Lizenz aus diesem Ticket erstellt.
+
+
+ + +{% endblock %} diff --git a/srv/tesm-license/templates/license_issue.html b/srv/tesm-license/templates/ticket_new.html similarity index 77% rename from srv/tesm-license/templates/license_issue.html rename to srv/tesm-license/templates/ticket_new.html index 2075377..4f8383a 100644 --- a/srv/tesm-license/templates/license_issue.html +++ b/srv/tesm-license/templates/ticket_new.html @@ -1,19 +1,17 @@ {% extends "base.html" %} {% set active_page = "index" %} -{% block page_title %}Lizenz ausstellen{% endblock %} -{% block page_sub %}
Neue signierte Lizenz für einen Kunden erzeugen
{% endblock %} +{% block page_title %}Neues Ticket{% endblock %} +{% block page_sub %}
Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten anlegen
{% endblock %} {% block content %} {% import "_hint_icon.html" as hi %}
- {% if not master_licensed %} -
- Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe Lizenz. - Ausstellen ist erst danach wieder möglich. +
+ Legt nur die Berechtigung an (Typ/Module/Laufzeit) — die eigentliche signierte Lizenzdatei wird danach auf der + Ticket-Seite erzeugt, durch dich oder direkt durch den Kunden selbst über seinen Self-Service-Link.
- {% endif %}
@@ -37,7 +35,7 @@
- +
-
-