Compare commits

1 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 7e39320c87 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/<id> (Details, Lizenz
  aus Ticket erstellen -- reguraer laut Ticket-Typ ODER als Trial mit
  vollem Modulumfang unabhaengig vom Ticket-Typ --, Ticket sperren/
  entsperren).
- /self-service/<ticket_id>: 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/<id>/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 <noreply@anthropic.com>
2026-08-23 11:41:51 +02:00
10 changed files with 680 additions and 103 deletions
+1 -1
View File
@@ -1 +1 @@
1 2
+1 -1
View File
@@ -1 +1 @@
1.0.1 1.1.0
+416 -56
View File
@@ -701,9 +701,61 @@ def _ensure_schema():
role TEXT NOT NULL DEFAULT 'customer', role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT, created_by TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
ticket_id TEXT,
FOREIGN KEY (customer_id) REFERENCES license_customers(id) 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" _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(): 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 return d
def _issue_license_for_customer(conn, customer_id, license_type, modules, valid_days, role="customer"): TRIAL_DEFAULT_DAYS = 30
"""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 def _ticket_row(conn, ticket_id):
_process_activate weiter unten).""" 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) 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( license_file, license_pubkey = licensing.issue_license(
customer={"name": customer["name"], "contact_email": customer["contact_email"] or ""}, customer={"name": customer["name"], "contact_email": customer["contact_email"] or ""},
license_type=license_type, 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") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute( conn.execute(
"INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, " "INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, "
"license_pubkey, license_file_json, status, role, created_by, created_at) " "license_pubkey, license_file_json, status, role, ticket_id, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?)", "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, license_file["issued_at"], license_file["expires_at"], license_pubkey,
json.dumps(license_file), role, json.dumps(license_file), ticket_id, actor, now,
current_user.username if current_user.is_authenticated else "system",
now,
), ),
) )
conn.commit() 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 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 ------------------- # --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung -------------------
# Ein Protokoll, zwei Transportwege (siehe licensing.py-Moduldocstring): # Ein Protokoll, zwei Transportwege (siehe licensing.py-Moduldocstring):
# dieselben drei Funktionen bedienen sowohl die Online-JSON-API # 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=?", "UPDATE licenses SET status='active', fingerprint=?, activated_at=? WHERE license_id=?",
(fingerprint, now, 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() conn.commit()
customer = _customer_row(conn, row["customer_id"]) customer = _customer_row(conn, row["customer_id"])
conn.close() conn.close()
@@ -2063,8 +2222,8 @@ def customers():
flash("Keine Berechtigung, Kunden zu löschen.", "danger") flash("Keine Berechtigung, Kunden zu löschen.", "danger")
else: else:
customer_id = int(request.form["delete_customer"]) customer_id = int(request.form["delete_customer"])
if conn.execute("SELECT 1 FROM licenses WHERE customer_id=?", (customer_id,)).fetchone(): if conn.execute("SELECT 1 FROM license_tickets WHERE customer_id=?", (customer_id,)).fetchone():
flash("Kunde hat noch Lizenzen -- kann nicht gelöscht werden.", "danger") flash("Kunde hat noch Tickets -- kann nicht gelöscht werden.", "danger")
else: else:
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,)) conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
conn.commit() conn.commit()
@@ -2074,9 +2233,9 @@ def customers():
return redirect(url_for("customers")) return redirect(url_for("customers"))
customers_rows = conn.execute(""" 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 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 GROUP BY license_customers.id
ORDER BY license_customers.name COLLATE NOCASE ORDER BY license_customers.name COLLATE NOCASE
""").fetchall() """).fetchall()
@@ -2088,26 +2247,25 @@ def customers():
) )
@app.route("/licenses/issue", methods=["GET", "POST"]) @app.route("/tickets/new", methods=["GET", "POST"])
@login_required @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"): 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")) return redirect(url_for("index"))
conn = get_db_connection() conn = get_db_connection()
if request.method == "POST": 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", "") customer_id = request.form.get("customer_id", "")
if customer_id == "__new__": if customer_id == "__new__":
new_customer_name = request.form.get("new_customer_name", "").strip() new_customer_name = request.form.get("new_customer_name", "").strip()
if not new_customer_name: if not new_customer_name:
flash("Bitte einen Namen für den neuen Kunden angeben.", "danger") flash("Bitte einen Namen für den neuen Kunden angeben.", "danger")
conn.close() conn.close()
return redirect(url_for("license_issue")) return redirect(url_for("ticket_new"))
cur = conn.execute( cur = conn.execute(
"INSERT INTO license_customers (name, contact_email, created_at) VALUES (?, ?, ?)", "INSERT INTO license_customers (name, contact_email, created_at) VALUES (?, ?, ?)",
(new_customer_name, request.form.get("new_customer_email", "").strip(), (new_customer_name, request.form.get("new_customer_email", "").strip(),
@@ -2115,20 +2273,20 @@ def license_issue():
) )
conn.commit() conn.commit()
customer_id = cur.lastrowid 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: else:
try: try:
customer_id = int(customer_id) customer_id = int(customer_id)
except ValueError: except ValueError:
flash("Ungültiger Kunde.", "danger") flash("Ungültiger Kunde.", "danger")
conn.close() conn.close()
return redirect(url_for("license_issue")) return redirect(url_for("ticket_new"))
license_type = request.form.get("license_type", "standard") license_type = request.form.get("license_type", "standard")
if license_type not in licensing.LICENSE_TYPES: if license_type not in licensing.LICENSE_TYPES:
flash("Ungültiger Lizenztyp.", "danger") flash("Ungültiger Lizenztyp.", "danger")
conn.close() conn.close()
return redirect(url_for("license_issue")) return redirect(url_for("ticket_new"))
if license_type == "enterprise": if license_type == "enterprise":
modules = list(licensing.ALL_MODULES) modules = list(licensing.ALL_MODULES)
elif license_type == "custom": elif license_type == "custom":
@@ -2140,21 +2298,79 @@ def license_issue():
except ValueError: except ValueError:
valid_days = 365 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() conn.close()
flash("Lizenz ausgestellt.", "success") flash("Ticket angelegt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"])) return redirect(url_for("ticket_detail", ticket_id=ticket_id))
customers_rows = conn.execute("SELECT * FROM license_customers ORDER BY name COLLATE NOCASE").fetchall() customers_rows = conn.execute("SELECT * FROM license_customers ORDER BY name COLLATE NOCASE").fetchall()
conn.close() conn.close()
return render_template( 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, 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), preselect_customer_id=request.args.get("customer_id", type=int),
) )
@app.route("/tickets/<ticket_id>", 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/<license_id>") @app.route("/licenses/<license_id>")
@login_required @login_required
def license_detail(license_id): def license_detail(license_id):
@@ -2287,12 +2503,20 @@ def license_send_email(license_id):
if not customer["contact_email"]: if not customer["contact_email"]:
flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger") flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger")
return redirect(url_for("license_detail", license_id=license_id)) 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: try:
_graph_send_mail( _graph_send_mail(
customer["contact_email"], customer["contact_email"],
f"Ihre TESM-Lizenz ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})", 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"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_bytes=row["license_file_json"].encode("utf-8"),
attachment_name=f"license-{license_id}.json", attachment_name=f"license-{license_id}.json",
) )
@@ -2312,21 +2536,138 @@ def license_revoke(license_id):
conn = get_db_connection() conn = get_db_connection()
row = _license_row(conn, license_id) row = _license_row(conn, license_id)
if row: if row:
conn.execute( _revoke_license_row(conn, row, current_user.username)
"UPDATE licenses SET status='revoked', revoked_at=? WHERE license_id=?", flash("Lizenz widerrufen -- der Kunde wird beim nächsten Heartbeat automatisch delizensiert. "
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), license_id), "Das Ticket bleibt offen, eine neue Lizenz kann jederzeit daraus erstellt werden.", "success")
)
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")
else: else:
flash("Lizenz nicht gefunden.", "danger") flash("Lizenz nicht gefunden.", "danger")
conn.close() conn.close()
return redirect(url_for("license_detail", license_id=license_id)) return redirect(url_for("license_detail", license_id=license_id))
@app.route("/licenses/<license_id>/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/<ticket_id>")
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/<ticket_id>/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/<ticket_id>/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/<ticket_id>/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(): def _audit_archive_loop():
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob """Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
die audit_log-Tabelle den Schwellenwert überschritten hat, und die audit_log-Tabelle den Schwellenwert überschritten hat, und
@@ -2700,23 +3041,42 @@ def inject_license_topbar():
@app.route("/") @app.route("/")
def index(): def index():
"""Kunden-/Lizenzübersicht -- das Dashboard des Master-Lizenzservers. """Kunden-/Ticket-/Lizenzübersicht -- das Dashboard des Master-
Ersetzt die geräte-/PoE-spezifische Kachelansicht von TESM (siehe Lizenzservers. Zeigt Tickets (die dauerhafte Berechtigung des Kunden
Modul-Docstring oben) komplett.""" 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"): 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() conn = get_db_connection()
rows = conn.execute(""" filter_customer_id = request.args.get("customer_id", type=int)
SELECT licenses.*, license_customers.name AS customer_name if filter_customer_id:
FROM licenses ticket_rows = conn.execute("""
JOIN license_customers ON license_customers.id = licenses.customer_id SELECT license_tickets.*, license_customers.name AS customer_name
ORDER BY licenses.created_at DESC FROM license_tickets
""").fetchall() JOIN license_customers ON license_customers.id = license_tickets.customer_id
conn.close() 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() 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( 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"), can_create=current_user.has_permission("licenses.create"),
) )
+15
View File
@@ -140,6 +140,21 @@ CREATE TABLE IF NOT EXISTS licenses (
role TEXT NOT NULL DEFAULT 'customer', role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT, created_by TEXT,
created_at TEXT NOT NULL, 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) FOREIGN KEY (customer_id) REFERENCES license_customers(id)
); );
""") """)
+5 -5
View File
@@ -29,22 +29,22 @@
<thead><tr> <thead><tr>
<th data-sort-key="name">Kunde</th> <th data-sort-key="name">Kunde</th>
<th data-sort-key="email">Kontakt</th> <th data-sort-key="email">Kontakt</th>
<th data-sort-key="licenses">Lizenzen</th> <th data-sort-key="tickets">Tickets</th>
<th style="width:1%;">Aktionen</th> <th style="width:1%;">Aktionen</th>
</tr></thead> </tr></thead>
{% for c in customers %} {% for c in customers %}
<tbody data-sort-name="{{ c.name|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-licenses="{{ c.license_count }}"> <tbody data-sort-name="{{ c.name|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}">
<tr> <tr>
<td class="cell-name">{{ c.name }}</td> <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">{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}</td>
<td class="text-dim">{{ c.license_count }}</td> <td class="text-dim"><a href="{{ url_for('index', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
<td> <td>
<div class="row-actions"> <div class="row-actions">
{% if can_edit %} {% if can_edit %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editCustomerModal{{ loop.index }}"> <button class="icon-btn" title="Bearbeiten" data-open-modal="editCustomerModal{{ 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> <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> </button>
{% if c.license_count == 0 %} {% if c.ticket_count == 0 %}
<form method="post" data-confirm="Kunde „{{ c.name }}“ wirklich löschen?"> <form method="post" data-confirm="Kunde „{{ c.name }}“ wirklich löschen?">
<input type="hidden" name="delete_customer" value="{{ c.id }}"> <input type="hidden" name="delete_customer" value="{{ c.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen"> <button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
@@ -54,7 +54,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if current_user.has_permission('licenses.create') %} {% if current_user.has_permission('licenses.create') %}
<a class="icon-btn" title="Lizenz ausstellen" href="{{ url_for('license_issue') }}?customer_id={{ c.id }}"> <a class="icon-btn" title="Neues Ticket" href="{{ url_for('ticket_new') }}?customer_id={{ c.id }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9m-4 0H3m2 0a2 2 0 100-4 2 2 0 000 4zm4 4h.01"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9m-4 0H3m2 0a2 2 0 100-4 2 2 0 000 4zm4 4h.01"/></svg>
</a> </a>
{% endif %} {% endif %}
+39 -28
View File
@@ -1,11 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "index" %} {% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %} {% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">Kunden-/Lizenzübersicht</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Kunden-/Ticket-Übersicht</div>{% endblock %}
{% block content %} {% block content %}
{% if licenses is none %} {% if tickets is none %}
<div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;"> <div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;">
<p class="text-faint" style="font-size:13px;"> <p class="text-faint" style="font-size:13px;">
{% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %} {% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %}
@@ -15,12 +15,13 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Lizenzen</h2> <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> </div>
{% if can_create %} {% if can_create %}
<a href="{{ url_for('license_issue') }}" class="btn btn-primary"> <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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Lizenz ausstellen Neues Ticket
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -29,45 +30,55 @@
<div class="table-toolbar"> <div class="table-toolbar">
<div class="search-input"> <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> <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="licenseSearch" placeholder="Lizenzen durchsuchen…" oninput="filterLicensesTable()"> <input type="text" id="ticketSearch" placeholder="Tickets durchsuchen…" oninput="filterTicketsTable()">
</div> </div>
</div> </div>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table" id="licensesTable" data-sortable> <table class="data-table" id="ticketsTable" data-sortable>
<thead><tr> <thead><tr>
<th data-sort-key="customer">Kunde</th> <th data-sort-key="customer">Kunde</th>
<th data-sort-key="type">Typ</th> <th data-sort-key="type">Typ</th>
<th>Module</th> <th>Module</th>
<th data-sort-key="expires">Ablauf</th> <th data-sort-key="ticketstatus">Ticket</th>
<th data-sort-key="status">Status</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 data-sort-key="heartbeat">Letzter Heartbeat</th>
<th style="width:1%;">Aktionen</th> <th style="width:1%;">Aktionen</th>
</tr></thead> </tr></thead>
{% for l in licenses %} {% for t in tickets %}
<tbody data-sort-customer="{{ l.customer_name|lower }}" data-sort-type="{{ l.type }}" <tbody data-sort-customer="{{ t.customer_name|lower }}" data-sort-type="{{ t.type }}"
data-sort-expires="{{ l.expires_at }}" data-sort-status="{{ l.status }}" data-sort-ticketstatus="{{ t.status }}"
data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}"> 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> <tr>
<td class="cell-name">{{ l.customer_name }}</td> <td class="cell-name">{{ t.customer_name }}</td>
<td>{{ type_labels.get(l.type, l.type) }}</td> <td>{{ type_labels.get(t.type, t.type) }}</td>
<td class="text-dim">{{ l.modules_list|join(', ') if l.modules_list else '—' }}</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"> <td class="text-dim">
{{ l.expires_at[:10] }} {{ t.license.expires_at[:10] }}
{% if l.status == 'active' and l.days_left is not none %} {% if t.license.status == 'active' and t.license.days_left is not none %}
{% if l.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span> {% if t.license.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
{% elif l.days_left <= 30 %}<span style="color:var(--warning);">({{ l.days_left }} Tage)</span> {% elif t.license.days_left <= 30 %}<span style="color:var(--warning);">({{ t.license.days_left }} Tage)</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
<td> <td>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}"> <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(l.status, l.status) }} {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
</span> </span>
</td> </td>
<td class="text-dim">{{ l.last_heartbeat_at or '—' }}</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> <td>
<div class="row-actions"> <div class="row-actions">
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}"> <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> <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> </a>
</div> </div>
@@ -76,7 +87,7 @@
</tbody> </tbody>
{% else %} {% else %}
<tbody data-sort-pinned> <tbody data-sort-pinned>
<tr class="empty-row"><td colspan="7">Noch keine Lizenzen ausgestellt.</td></tr> <tr class="empty-row"><td colspan="8">Noch keine Tickets angelegt.</td></tr>
</tbody> </tbody>
{% endfor %} {% endfor %}
</table> </table>
@@ -88,11 +99,11 @@
{% block scripts %} {% block scripts %}
<script> <script>
function filterLicensesTable() { function filterTicketsTable() {
const el = document.getElementById("licenseSearch"); const el = document.getElementById("ticketSearch");
if (!el) return; if (!el) return;
const q = el.value.trim().toLowerCase(); const q = el.value.trim().toLowerCase();
document.querySelectorAll("#licensesTable tbody").forEach(tbody => { document.querySelectorAll("#ticketsTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return; if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none"; tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
}); });
@@ -10,6 +10,9 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Status</h2> <h2 style="font-size:16px;">Status</h2>
{% if license.ticket_id %}
<div class="hint"><a href="{{ url_for('ticket_detail', ticket_id=license.ticket_id) }}">← Zum Ticket</a></div>
{% endif %}
</div> </div>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(license.status, 'disabled') }}"> <span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(license.status, 'disabled') }}">
{{ {"issued": "Ausgestellt (nicht aktiviert)", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(license.status, license.status) }} {{ {"issued": "Ausgestellt (nicht aktiviert)", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(license.status, license.status) }}
@@ -67,6 +70,15 @@
{% if current_user.has_permission('licenses.edit') %} {% if current_user.has_permission('licenses.edit') %}
<a href="{{ url_for('license_manual_code') }}" class="btn btn-secondary btn-block" style="margin-top:10px;">Offline-Code verarbeiten</a> <a href="{{ url_for('license_manual_code') }}" class="btn btn-secondary btn-block" style="margin-top:10px;">Offline-Code verarbeiten</a>
{% endif %} {% endif %}
{% if can_edit and license.status in ('revoked', 'issued') %}
<form method="post" action="{{ url_for('license_delete', license_id=license.license_id) }}" style="margin-top:10px;"
data-confirm="Lizenz wirklich löschen? Das zugehörige Ticket bleibt bestehen -- eine neue Lizenz mit denselben Eckdaten kann jederzeit daraus erstellt werden.">
<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>
Lizenz löschen
</button>
</form>
{% endif %}
</div> </div>
</div> </div>
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meine Lizenz · 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:520px;">
<h1>Meine Lizenz</h1>
<div class="hint" style="text-align:center; margin-bottom:18px;">{{ customer.name if customer else '' }}</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 %}
{% if ticket.status != 'open' %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Dieses Ticket ist gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.
</div>
{% endif %}
<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>
{% if license %}
<div class="field">
<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>
{% 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;">
Lizenzdatei herunterladen
</a>
<form method="post" action="{{ url_for('self_service_revoke', ticket_id=ticket.id) }}"
data-confirm="Lizenz wirklich widerrufen? Das aktuelle System verliert danach den Zugriff. Sie können anschließend jederzeit eine neue Lizenz erstellen.">
<button type="submit" class="btn btn-danger btn-block">Lizenz widerrufen (z.B. bei Systemwechsel)</button>
</form>
{% else %}
<a href="{{ url_for('self_service_download', ticket_id=ticket.id) }}" class="btn btn-secondary btn-block">
Letzte Lizenzdatei herunterladen
</a>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für dieses Ticket wurde noch keine Lizenz erstellt.</p>
{% endif %}
{% if can_issue_new %}
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<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)
</button>
</form>
<form method="post" action="{{ url_for('self_service_issue', ticket_id=ticket.id) }}">
<input type="hidden" name="issue_license" value="trial">
<button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button>
</form>
{% endif %}
<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>
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Ticket{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Ticket</h2></div>
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span>
</div>
<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>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>
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
</div>
{% if can_edit %}
<form method="post" style="margin-top:14px;">
<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' }}
</button>
</form>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Lizenz aus diesem Ticket erstellen</h2></div>
</div>
{% if not master_licensed %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
</div>
{% elif ticket.status != 'open' %}
<p class="text-faint" style="font-size:12.5px;">Ticket ist gesperrt — bitte zuerst entsperren.</p>
{% elif has_open_license %}
<p class="text-faint" style="font-size:12.5px;">
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
eine neue erstellt werden.
</p>
{% elif can_edit %}
<form method="post" style="margin-bottom:10px;">
<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)
</button>
</form>
<form method="post">
<input type="hidden" name="issue_license" value="trial">
<button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button>
</form>
{% endif %}
</div>
<div class="card card-pad" style="grid-column: 1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Lizenzen zu diesem Ticket</h2></div>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Lizenz-ID</th><th>Typ</th><th>Ablauf</th><th>Status</th><th style="width:1%;">Aktionen</th></tr></thead>
{% for l in licenses %}
<tbody>
<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>
<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>
<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>
</td>
</tr>
</tbody>
{% else %}
<tbody><tr class="empty-row"><td colspan="5">Noch keine Lizenz aus diesem Ticket erstellt.</td></tr></tbody>
{% endfor %}
</table>
</div>
</div>
</div>
{% endblock %}
@@ -1,19 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "index" %} {% set active_page = "index" %}
{% block page_title %}Lizenz ausstellen{% endblock %} {% block page_title %}Neues Ticket{% endblock %}
{% block page_sub %}<div class="topbar-sub">Neue signierte Lizenz für einen Kunden erzeugen</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten anlegen</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %} {% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
{% if not master_licensed %} <div class="hint" style="margin-bottom:16px;">
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;"> Legt nur die Berechtigung an (Typ/Module/Laufzeit) — die eigentliche signierte Lizenzdatei wird danach auf der
Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>. Ticket-Seite erzeugt, durch dich oder direkt durch den Kunden selbst über seinen Self-Service-Link.
Ausstellen ist erst danach wieder möglich.
</div> </div>
{% endif %}
<form method="post"> <form method="post">
<div class="field"> <div class="field">
@@ -37,7 +35,7 @@
</div> </div>
<div class="field"> <div class="field">
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles.", "Lizenztyp") }}</label> <label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "Lizenztyp") }}</label>
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required> <select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
{% for t in license_types %} {% for t in license_types %}
<option value="{{ t }}" {% if t == 'standard' %}selected{% endif %}>{{ type_labels.get(t, t) }}</option> <option value="{{ t }}" {% if t == 'standard' %}selected{% endif %}>{{ type_labels.get(t, t) }}</option>
@@ -58,18 +56,17 @@
</div> </div>
<div class="field"> <div class="field">
<label for="valid_days">Gültigkeitsdauer (Tage) {{ hi.hint_icon("365 = 1 Jahr, 730 = 2 Jahre. Frei wählbar.", "Gültigkeitsdauer") }}</label> <label for="valid_days">Gültigkeitsdauer (Tage)</label>
<input type="number" name="valid_days" id="valid_days" value="365" min="1" required> <input type="number" name="valid_days" id="valid_days" value="365" min="1" required>
<div class="field-hint"> <div class="field-hint">
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=30">30 Tage (Trial)</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=365">1 Jahr</button> <button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=365">1 Jahr</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=730">2 Jahre</button> <button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=730">2 Jahre</button>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block" {% if not master_licensed %}disabled{% endif %}> <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> <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>
Lizenz ausstellen Ticket anlegen
</button> </button>
</form> </form>
</div> </div>