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>
This commit is contained in:
@@ -1 +1 @@
|
|||||||
1
|
2
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.1
|
1.1.0
|
||||||
|
|||||||
+416
-56
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
+9
-12
@@ -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>
|
||||||
Reference in New Issue
Block a user