Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c130c85b17 | ||
|
|
ed2ed00a4e | ||
|
|
286a3464f1 | ||
|
|
619673ac76 |
@@ -1 +1 @@
|
|||||||
1.2.3
|
1.2.7
|
||||||
|
|||||||
+486
-39
@@ -212,6 +212,10 @@ PERMISSIONS = {
|
|||||||
"label": "Kunden",
|
"label": "Kunden",
|
||||||
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
|
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
|
||||||
},
|
},
|
||||||
|
"tickets": {
|
||||||
|
"label": "Tickets",
|
||||||
|
"rows": {"view": "tickets.view", "create": "tickets.create", "edit": "tickets.edit"},
|
||||||
|
},
|
||||||
"licenses": {
|
"licenses": {
|
||||||
"label": "Lizenzen",
|
"label": "Lizenzen",
|
||||||
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.edit"},
|
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.edit"},
|
||||||
@@ -251,6 +255,10 @@ PERMISSIONS = {
|
|||||||
"label": "Systemeinstellungen",
|
"label": "Systemeinstellungen",
|
||||||
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
|
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
|
||||||
},
|
},
|
||||||
|
"settings_license": {
|
||||||
|
"label": "Lizenz",
|
||||||
|
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
|
||||||
|
},
|
||||||
"settings_importexport": {
|
"settings_importexport": {
|
||||||
"label": "Im-/Export",
|
"label": "Im-/Export",
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -275,6 +283,8 @@ PERMISSION_LABELS = {
|
|||||||
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
|
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
|
||||||
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
|
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
|
||||||
"customers.edit": "Kunden ändern (inkl. Löschen)",
|
"customers.edit": "Kunden ändern (inkl. Löschen)",
|
||||||
|
"tickets.view": "Tickets lesen", "tickets.create": "Tickets anlegen",
|
||||||
|
"tickets.edit": "Tickets ändern (Eckdaten, Sperren/Entsperren, Löschen)",
|
||||||
"licenses.view": "Lizenzen und Aktivierungsstatus lesen",
|
"licenses.view": "Lizenzen und Aktivierungsstatus lesen",
|
||||||
"licenses.create": "Lizenzen ausstellen",
|
"licenses.create": "Lizenzen ausstellen",
|
||||||
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
|
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
|
||||||
@@ -288,6 +298,8 @@ PERMISSION_LABELS = {
|
|||||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||||
"groups.edit": "Gruppen ändern (inkl. Löschen)",
|
"groups.edit": "Gruppen ändern (inkl. Löschen)",
|
||||||
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
|
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
|
||||||
|
"settings_license.view": "Lizenzstatus ansehen",
|
||||||
|
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
|
||||||
"settings_importexport.view": "Im-/Export-Seite ansehen",
|
"settings_importexport.view": "Im-/Export-Seite ansehen",
|
||||||
"settings_importexport.edit": "Daten importieren",
|
"settings_importexport.edit": "Daten importieren",
|
||||||
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
|
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
|
||||||
@@ -349,7 +361,7 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "index":
|
if key == "index":
|
||||||
return True
|
return True
|
||||||
if key == "tickets":
|
if key == "tickets":
|
||||||
return user.has_permission("licenses.view")
|
return user.has_permission("tickets.view")
|
||||||
if key == "licenses":
|
if key == "licenses":
|
||||||
return user.has_permission("licenses.view")
|
return user.has_permission("licenses.view")
|
||||||
if key == "customers":
|
if key == "customers":
|
||||||
@@ -361,10 +373,11 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "settings_system":
|
if key == "settings_system":
|
||||||
return user.can_view_settings_system
|
return user.can_view_settings_system
|
||||||
if key == "settings_license":
|
if key == "settings_license":
|
||||||
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
|
# Eigenes Recht (settings_license.view), unabhängig von
|
||||||
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
|
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
|
||||||
# jemals eine erste Lizenz hochladen/aktivieren.
|
# bleiben (das ist eine andere Prüfung, siehe license_active()),
|
||||||
return user.can_view_settings_system
|
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
|
||||||
|
return user.can_view_settings_license
|
||||||
if key == "settings_importexport":
|
if key == "settings_importexport":
|
||||||
return user.can_view_settings_importexport
|
return user.can_view_settings_importexport
|
||||||
if key == "settings_ldap":
|
if key == "settings_ldap":
|
||||||
@@ -537,6 +550,12 @@ class User(UserMixin):
|
|||||||
["customers.view", "customers.create", "customers.edit"]
|
["customers.view", "customers.create", "customers.edit"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_manage_tickets(self):
|
||||||
|
return self.has_any_permission(
|
||||||
|
["tickets.view", "tickets.create", "tickets.edit"]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_manage_licenses(self):
|
def can_manage_licenses(self):
|
||||||
return self.has_any_permission(
|
return self.has_any_permission(
|
||||||
@@ -559,6 +578,10 @@ class User(UserMixin):
|
|||||||
def can_view_settings_system(self):
|
def can_view_settings_system(self):
|
||||||
return self.has_permission("settings_system.view")
|
return self.has_permission("settings_system.view")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_view_settings_license(self):
|
||||||
|
return self.has_permission("settings_license.view")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_view_settings_importexport(self):
|
def can_view_settings_importexport(self):
|
||||||
return self.has_any_permission([
|
return self.has_any_permission([
|
||||||
@@ -781,6 +804,7 @@ def _ensure_schema():
|
|||||||
valid_days INTEGER NOT NULL,
|
valid_days INTEGER NOT NULL,
|
||||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
role TEXT NOT NULL DEFAULT 'customer',
|
||||||
created_by TEXT,
|
created_by TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||||
@@ -789,6 +813,13 @@ def _ensure_schema():
|
|||||||
ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()}
|
ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()}
|
||||||
if "lifetime" not in ticket_cols:
|
if "lifetime" not in ticket_cols:
|
||||||
conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "role" not in ticket_cols:
|
||||||
|
# Rolle der aus diesem Ticket auszustellenden Lizenz (siehe
|
||||||
|
# licensing.LICENSE_ROLES) -- "customer" (Default) fuer gewoehnliche
|
||||||
|
# TESM-Kundeninstanzen, "license_server" fuer einen damit
|
||||||
|
# auszustellenden Sub-Lizenzserver. Bestehende Tickets bleiben
|
||||||
|
# unveraendert "customer".
|
||||||
|
conn.execute("ALTER TABLE license_tickets ADD COLUMN role TEXT NOT NULL DEFAULT 'customer'")
|
||||||
|
|
||||||
# DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne
|
# DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne
|
||||||
# dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein
|
# dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein
|
||||||
@@ -850,6 +881,64 @@ def _ensure_schema():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Sub-Lizenzserver-Reporting (siehe role="license_server" in
|
||||||
|
# licensing.py): ein Sub-Lizenzserver meldet bei jedem Heartbeat (und
|
||||||
|
# sofort nach jeder lokalen Aenderung, siehe _report_to_master_async())
|
||||||
|
# einen vollstaendigen Schnappschuss seiner EIGENEN Kunden/Tickets/
|
||||||
|
# Lizenzen. Bewusst DREI EIGENE Tabellen statt Wiederverwendung von
|
||||||
|
# license_customers/license_tickets/licenses -- das hier ist rein
|
||||||
|
# gemeldete, von diesem Master aus nicht editierbare Fremd-Information
|
||||||
|
# (der Master verwaltet diese Daten nicht selbst, er zeigt sie nur an),
|
||||||
|
# eine Vermischung mit selbst verwalteten Datensaetzen wuerde das
|
||||||
|
# verwischen. source_license_id verankert jede Zeile an genau der
|
||||||
|
# Lizenz (role='license_server'), die diesen Sub-Lizenzserver auf
|
||||||
|
# diesem Master identifiziert -- bei jedem neuen Bericht werden alle
|
||||||
|
# drei Tabellen fuer diese source_license_id komplett ersetzt (siehe
|
||||||
|
# _ingest_sub_report()), kein Diffing noetig, da ein voller
|
||||||
|
# Schnappschuss geschickt wird.
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reported_sub_customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_license_id TEXT NOT NULL,
|
||||||
|
remote_customer_ref TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
contact_email TEXT, contact_phone TEXT, contact_person TEXT,
|
||||||
|
street TEXT, house_number TEXT, postal_code TEXT, city TEXT,
|
||||||
|
last_reported_at TEXT NOT NULL,
|
||||||
|
UNIQUE (source_license_id, remote_customer_ref)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reported_sub_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_license_id TEXT NOT NULL,
|
||||||
|
remote_ticket_id TEXT NOT NULL,
|
||||||
|
remote_customer_ref TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
|
||||||
|
valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer',
|
||||||
|
created_at TEXT,
|
||||||
|
last_reported_at TEXT NOT NULL,
|
||||||
|
UNIQUE (source_license_id, remote_ticket_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reported_sub_licenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_license_id TEXT NOT NULL,
|
||||||
|
remote_license_id TEXT NOT NULL,
|
||||||
|
remote_ticket_id TEXT,
|
||||||
|
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
|
||||||
|
issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT,
|
||||||
|
last_reported_at TEXT NOT NULL,
|
||||||
|
UNIQUE (source_license_id, remote_license_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)")
|
||||||
|
|
||||||
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
|
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
|
||||||
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
|
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
|
||||||
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
|
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
|
||||||
@@ -877,6 +966,64 @@ def _ensure_schema():
|
|||||||
conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"]))
|
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,))
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,))
|
||||||
|
|
||||||
|
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
|
||||||
|
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
|
||||||
|
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
|
||||||
|
# die Seite lief komplett über settings_system.view/.edit mit.
|
||||||
|
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
|
||||||
|
# hatte, bekommt automatisch das neue, gespiegelte
|
||||||
|
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
|
||||||
|
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
|
||||||
|
# die Lizenzseite verlieren, den sie vorher implizit hatten.
|
||||||
|
_system_to_license = {
|
||||||
|
"settings_system.view": "settings_license.view",
|
||||||
|
"settings_system.edit": "settings_license.edit",
|
||||||
|
}
|
||||||
|
for system_perm, license_perm in _system_to_license.items():
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||||
|
(row["group_id"], license_perm),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
|
||||||
|
)
|
||||||
|
|
||||||
|
_migration_key_tickets_perm = "_migrated_tickets_permission_v1"
|
||||||
|
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets_perm,)).fetchone():
|
||||||
|
# "Tickets" hatte bislang KEIN eigenes Recht -- Ticketverwaltung
|
||||||
|
# (anlegen/bearbeiten/sperren/loeschen) lief komplett ueber
|
||||||
|
# licenses.view/.create/.edit mit, obwohl Tickets ein eigener,
|
||||||
|
# gleichberechtigter Navigationspunkt neben Kunden und Lizenzen
|
||||||
|
# ist (genau wie bei TESM Clients/Switche/Zugangsdaten trotz enger
|
||||||
|
# fachlicher Verwandtschaft jeweils eigene Rechte haben). Backfill:
|
||||||
|
# jede Gruppe mit licenses.view/.create/.edit bekommt automatisch
|
||||||
|
# das gespiegelte tickets.view/.create/.edit dazu -- das
|
||||||
|
# Ausstellen einer Lizenz AUS einem Ticket heraus bleibt bewusst
|
||||||
|
# weiterhin an licenses.edit gebunden (siehe ticket_detail()),
|
||||||
|
# nur Sperren/Entsperren/Bearbeiten/Loeschen des Tickets selbst
|
||||||
|
# wandert auf das neue, unabhaengige Recht.
|
||||||
|
_license_to_ticket = {
|
||||||
|
"licenses.view": "tickets.view",
|
||||||
|
"licenses.create": "tickets.create",
|
||||||
|
"licenses.edit": "tickets.edit",
|
||||||
|
}
|
||||||
|
for license_perm, ticket_perm in _license_to_ticket.items():
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (license_perm,)
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||||
|
(row["group_id"], ticket_perm),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets_perm,)
|
||||||
|
)
|
||||||
|
|
||||||
_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():
|
||||||
for stale_log in glob.glob("/var/log/rpi-*.log"):
|
for stale_log in glob.glob("/var/log/rpi-*.log"):
|
||||||
@@ -1806,6 +1953,24 @@ def module_licensed(module_key):
|
|||||||
return license_active() and module_key in _license_state["status"]["modules"]
|
return license_active() and module_key in _license_state["status"]["modules"]
|
||||||
|
|
||||||
|
|
||||||
|
def can_issue_license_server_licenses():
|
||||||
|
"""STRUKTURELLE Sperre (siehe licensing.LICENSE_ROLES-Kommentar), keine
|
||||||
|
vergebbare Permission: True nur, wenn die EIGENE, gerade aktive Lizenz
|
||||||
|
dieser Instanz role=='master' ist -- also ausschließlich auf der
|
||||||
|
tatsächlichen Wurzel-Instanz (deren Lizenz per create_master_license.py
|
||||||
|
lokal erzeugt wurde), niemals auf einem per role='license_server'
|
||||||
|
lizenzierten Sub-Lizenzserver. Ein Sub-Lizenzserver liefert hier immer
|
||||||
|
False zurück, unabhängig davon, welche Gruppenrechte seine lokalen
|
||||||
|
Admins dort einrichten -- diese Sperre lässt sich also NICHT durch
|
||||||
|
lokale Rechtevergabe aufheben. Aufrufer müssen zusätzlich
|
||||||
|
current_user.is_admin selbst prüfen (siehe ticket_new()) -- diese
|
||||||
|
Funktion prüft nur die Instanz-Rolle, keine Benutzerrechte."""
|
||||||
|
return bool(
|
||||||
|
license_active()
|
||||||
|
and _license_state["status"]["role"] == "master"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_module(module_key):
|
def _require_module(module_key):
|
||||||
"""Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
|
"""Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
|
||||||
(dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
|
(dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
|
||||||
@@ -1855,6 +2020,56 @@ def _license_api_post(endpoint, path, payload, timeout=10):
|
|||||||
return json.loads(resp.read().decode("utf-8"))
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
MAX_REPORT_ROWS = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def _build_own_issuance_report(conn):
|
||||||
|
"""Nur relevant, wenn DIESE Instanz selbst ein Sub-Lizenzserver ist
|
||||||
|
(eigene Lizenz mit role='license_server', siehe licensing.LICENSE_ROLES)
|
||||||
|
-- baut einen vollstaendigen Schnappschuss der EIGENEN, von dieser
|
||||||
|
Instanz selbst verwalteten Kunden/Tickets/Lizenzen (NICHT die eigene
|
||||||
|
Bootstrap-Lizenz dieser Instanz selbst -- die steht in license.json,
|
||||||
|
nicht in diesen Tabellen). Wird bei jedem Heartbeat an den Master
|
||||||
|
mitgeschickt (siehe _send_heartbeat()), der daraus seine
|
||||||
|
reported_sub_*-Tabellen befuellt (siehe _ingest_sub_report() auf der
|
||||||
|
Master-Seite). Ein voller Schnappschuss statt Deltas -- deutlich
|
||||||
|
einfacher korrekt zu halten (keine Delta-/Loeschverfolgung noetig),
|
||||||
|
bei den hier realistischen Datenmengen (Kunden eines einzelnen
|
||||||
|
Sub-Lizenzservers) unproblematisch."""
|
||||||
|
customers = conn.execute(
|
||||||
|
"SELECT id, name, contact_email, contact_phone, contact_person, "
|
||||||
|
"street, house_number, postal_code, city FROM license_customers "
|
||||||
|
"ORDER BY id LIMIT ?", (MAX_REPORT_ROWS,)
|
||||||
|
).fetchall()
|
||||||
|
tickets = conn.execute(
|
||||||
|
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at "
|
||||||
|
"FROM license_tickets ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,)
|
||||||
|
).fetchall()
|
||||||
|
licenses_rows = conn.execute(
|
||||||
|
"SELECT license_id, ticket_id, type, modules, issued_at, expires_at, status, lifetime, "
|
||||||
|
"fingerprint, hostname, activated_at, last_heartbeat_at "
|
||||||
|
"FROM licenses ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,)
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"customers": [dict(r) for r in customers],
|
||||||
|
"tickets": [dict(r) for r in tickets],
|
||||||
|
"licenses": [dict(r) for r in licenses_rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report_to_master_async():
|
||||||
|
"""Feuert einen sofortigen Best-Effort-Heartbeat (inkl. Bericht, siehe
|
||||||
|
_build_own_issuance_report) in einem eigenen Hintergrund-Thread aus --
|
||||||
|
aufgerufen direkt nach jeder lokalen Ticket-/Lizenz-Aenderung dieser
|
||||||
|
Instanz, damit der Master zeitnah einen aktuellen Stand sieht, statt bis
|
||||||
|
zu LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h) warten zu muessen. Laeuft
|
||||||
|
bewusst asynchron und schluckt jeden Fehler (siehe _send_heartbeat) --
|
||||||
|
ein nicht erreichbarer Master darf eine lokale Ticket-/Lizenzaktion
|
||||||
|
niemals verzoegern oder fehlschlagen lassen."""
|
||||||
|
if _license_state.get("status") and _license_state["status"].get("role") == "license_server":
|
||||||
|
threading.Thread(target=_send_heartbeat, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _send_heartbeat():
|
def _send_heartbeat():
|
||||||
"""Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
|
"""Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
|
||||||
master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
|
master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
|
||||||
@@ -1869,7 +2084,14 @@ def _send_heartbeat():
|
|||||||
endpoint = license_file.get("master_endpoint", "")
|
endpoint = license_file.get("master_endpoint", "")
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return
|
return
|
||||||
request_payload = licensing.build_client_request("heartbeat", license_file)
|
report = None
|
||||||
|
if _license_state.get("status", {}).get("role") == "license_server":
|
||||||
|
report_conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
report = _build_own_issuance_report(report_conn)
|
||||||
|
finally:
|
||||||
|
report_conn.close()
|
||||||
|
request_payload = licensing.build_client_request("heartbeat", license_file, report=report)
|
||||||
try:
|
try:
|
||||||
response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
|
response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -2079,7 +2301,7 @@ def _ticket_has_open_license(conn, ticket_id):
|
|||||||
return bool(current and current["status"] in ("issued", "active"))
|
return bool(current and current["status"] in ("issued", "active"))
|
||||||
|
|
||||||
|
|
||||||
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False):
|
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False, role="customer"):
|
||||||
"""Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende
|
"""Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende
|
||||||
Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich
|
Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich
|
||||||
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket.
|
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket.
|
||||||
@@ -2087,21 +2309,27 @@ def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor,
|
|||||||
aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe
|
aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe
|
||||||
_issue_license_from_ticket) -- der Wert wird trotzdem gespeichert,
|
_issue_license_from_ticket) -- der Wert wird trotzdem gespeichert,
|
||||||
damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei
|
damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei
|
||||||
0/NULL anfangen muss."""
|
0/NULL anfangen muss. role (siehe licensing.LICENSE_ROLES) bestimmt,
|
||||||
|
welche Rolle eine aus diesem Ticket ausgestellte Lizenz traegt --
|
||||||
|
Aufrufer MUESSEN role='license_server' bereits selbst gegen
|
||||||
|
can_issue_license_server_licenses() + current_user.is_admin geprueft
|
||||||
|
haben (siehe ticket_new()), diese Funktion vertraut dem Aufrufer."""
|
||||||
ticket_id = secrets.token_urlsafe(24)
|
ticket_id = secrets.token_urlsafe(24)
|
||||||
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 license_tickets (id, customer_id, type, modules, valid_days, lifetime, status, created_by, created_at) "
|
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, role, status, created_by, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)",
|
||||||
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), actor, now),
|
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), role, actor, now),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
customer = _customer_row(conn, customer_id)
|
customer = _customer_row(conn, customer_id)
|
||||||
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
|
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
|
||||||
|
role_label = " -- LIZENZSERVER" if role == "license_server" else ""
|
||||||
log_action_system(
|
log_action_system(
|
||||||
"ticket.create", customer["name"] if customer else str(customer_id),
|
"ticket.create", customer["name"] if customer else str(customer_id),
|
||||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label} (Akteur: {actor})",
|
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}{role_label} (Akteur: {actor})",
|
||||||
)
|
)
|
||||||
|
_report_to_master_async()
|
||||||
return ticket_id
|
return ticket_id
|
||||||
|
|
||||||
|
|
||||||
@@ -2116,7 +2344,14 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
|
|||||||
Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt
|
Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt
|
||||||
Trial-Konditionen."""
|
Trial-Konditionen."""
|
||||||
ticket = _ticket_row(conn, ticket_id)
|
ticket = _ticket_row(conn, ticket_id)
|
||||||
|
ticket_role = ticket["role"] if "role" in ticket.keys() else "customer"
|
||||||
if as_trial:
|
if as_trial:
|
||||||
|
if ticket_role != "customer":
|
||||||
|
# Trial ("30 Tage, alle TESM-Module") ist ein reines
|
||||||
|
# Kunden-Konzept -- ALL_MODULES (dhcp/fileshare/maintenance)
|
||||||
|
# ist fuer eine Lizenzserver-Lizenz bedeutungslos. Klarer
|
||||||
|
# Fehler statt einer stillschweigend falsch befuellten Lizenz.
|
||||||
|
raise ValueError("Trial-Ausstellung ist nur fuer Kunden-Tickets moeglich.")
|
||||||
# Trial ist immer ein zeitlich kurzer Override, unabhaengig davon
|
# Trial ist immer ein zeitlich kurzer Override, unabhaengig davon
|
||||||
# ob das Ticket selbst als Lifetime konfiguriert ist -- siehe
|
# ob das Ticket selbst als Lifetime konfiguriert ist -- siehe
|
||||||
# Modul-Docstring oben.
|
# Modul-Docstring oben.
|
||||||
@@ -2145,17 +2380,18 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
|
|||||||
master_endpoint=_get_master_endpoint(),
|
master_endpoint=_get_master_endpoint(),
|
||||||
vendor=_get_vendor_info(),
|
vendor=_get_vendor_info(),
|
||||||
lifetime=lifetime,
|
lifetime=lifetime,
|
||||||
|
role=ticket_role,
|
||||||
)
|
)
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
try:
|
try:
|
||||||
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, ticket_id, lifetime, created_by, created_at) "
|
"license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
license_file["license_id"], ticket["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), ticket_id, int(lifetime), actor, now,
|
json.dumps(license_file), ticket_role, ticket_id, int(lifetime), actor, now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -2179,6 +2415,7 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
|
|||||||
"license.issue", customer["name"] if customer else "?",
|
"license.issue", customer["name"] if customer else "?",
|
||||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})",
|
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})",
|
||||||
)
|
)
|
||||||
|
_report_to_master_async()
|
||||||
return license_file
|
return license_file
|
||||||
|
|
||||||
|
|
||||||
@@ -2199,6 +2436,7 @@ def _revoke_license_row(conn, license_row, actor):
|
|||||||
"license.revoke", customer["name"] if customer else license_row["license_id"],
|
"license.revoke", customer["name"] if customer else license_row["license_id"],
|
||||||
f"Akteur: {actor}",
|
f"Akteur: {actor}",
|
||||||
)
|
)
|
||||||
|
_report_to_master_async()
|
||||||
|
|
||||||
|
|
||||||
# --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung -------------------
|
# --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung -------------------
|
||||||
@@ -2314,6 +2552,64 @@ def _process_deactivate(data):
|
|||||||
return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_sub_report(conn, source_license_id, report):
|
||||||
|
"""Uebernimmt einen von einem Sub-Lizenzserver per Heartbeat gemeldeten
|
||||||
|
Schnappschuss (siehe _build_own_issuance_report() auf dessen Seite) in
|
||||||
|
die reported_sub_*-Tabellen dieses Masters -- rein informativ, dieser
|
||||||
|
Master verwaltet diese Datensaetze nicht selbst. Ersetzt bei jedem
|
||||||
|
Bericht ALLE bisherigen Zeilen fuer diese source_license_id komplett
|
||||||
|
(kein Diffing) -- ein voller Schnappschuss macht z.B. beim Sub-
|
||||||
|
Lizenzserver geloeschte Tickets/Kunden automatisch korrekt verschwinden,
|
||||||
|
ohne eigene Loeschverfolgung. Aufrufer MUSS bereits geprueft haben, dass
|
||||||
|
die zugehoerige Lizenz tatsaechlich role='license_server' traegt (siehe
|
||||||
|
_process_heartbeat) -- diese Funktion vertraut dem Aufrufer."""
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
return
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
conn.execute("DELETE FROM reported_sub_licenses WHERE source_license_id=?", (source_license_id,))
|
||||||
|
conn.execute("DELETE FROM reported_sub_tickets WHERE source_license_id=?", (source_license_id,))
|
||||||
|
conn.execute("DELETE FROM reported_sub_customers WHERE source_license_id=?", (source_license_id,))
|
||||||
|
for c in (report.get("customers") or [])[:MAX_REPORT_ROWS]:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO reported_sub_customers (source_license_id, remote_customer_ref, name, "
|
||||||
|
"contact_email, contact_phone, contact_person, street, house_number, postal_code, city, "
|
||||||
|
"last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(source_license_id, str(c.get("id", "")), c.get("name", "") or "?",
|
||||||
|
c.get("contact_email"), c.get("contact_phone"), c.get("contact_person"),
|
||||||
|
c.get("street"), c.get("house_number"), c.get("postal_code"), c.get("city"), now),
|
||||||
|
)
|
||||||
|
except (sqlite3.IntegrityError, TypeError, KeyError):
|
||||||
|
continue
|
||||||
|
for t in (report.get("tickets") or [])[:MAX_REPORT_ROWS]:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO reported_sub_tickets (source_license_id, remote_ticket_id, remote_customer_ref, "
|
||||||
|
"type, modules, valid_days, lifetime, status, role, created_at, last_reported_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(source_license_id, str(t.get("id", "")), str(t.get("customer_id", "")),
|
||||||
|
t.get("type", "standard"), t.get("modules", "[]"), t.get("valid_days"),
|
||||||
|
int(bool(t.get("lifetime"))), t.get("status", "open"), t.get("role", "customer"),
|
||||||
|
t.get("created_at"), now),
|
||||||
|
)
|
||||||
|
except (sqlite3.IntegrityError, TypeError, KeyError):
|
||||||
|
continue
|
||||||
|
for l in (report.get("licenses") or [])[:MAX_REPORT_ROWS]:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO reported_sub_licenses (source_license_id, remote_license_id, remote_ticket_id, "
|
||||||
|
"type, modules, issued_at, expires_at, status, lifetime, fingerprint, hostname, "
|
||||||
|
"activated_at, last_heartbeat_at, last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(source_license_id, l.get("license_id", ""), l.get("ticket_id"),
|
||||||
|
l.get("type", "standard"), l.get("modules", "[]"), l.get("issued_at"), l.get("expires_at"),
|
||||||
|
l.get("status", "issued"), int(bool(l.get("lifetime"))), l.get("fingerprint"),
|
||||||
|
l.get("hostname"), l.get("activated_at"), l.get("last_heartbeat_at"), now),
|
||||||
|
)
|
||||||
|
except (sqlite3.IntegrityError, TypeError, KeyError):
|
||||||
|
continue
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _process_heartbeat(data):
|
def _process_heartbeat(data):
|
||||||
license_id = data.get("license_id")
|
license_id = data.get("license_id")
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2346,6 +2642,14 @@ def _process_heartbeat(data):
|
|||||||
(now, hostname, license_id),
|
(now, hostname, license_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Sub-Lizenzserver-Reporting: NUR uebernehmen, wenn die Lizenz dieses
|
||||||
|
# Absenders tatsaechlich role='license_server' traegt -- Verteidigung
|
||||||
|
# in der Tiefe, ein gewoehnlicher Kunde koennte sonst versuchen, einen
|
||||||
|
# "report"-Payload unterzuschieben, um sich als Lizenzserver
|
||||||
|
# auszugeben. row['role'] steht in der Master-DB fest (beim Ausstellen
|
||||||
|
# gesetzt, siehe _issue_license_from_ticket), nicht im Client-Request.
|
||||||
|
if row["role"] == "license_server" and data.get("report"):
|
||||||
|
_ingest_sub_report(conn, license_id, data["report"])
|
||||||
license_update = json.loads(row["license_file_json"])
|
license_update = json.loads(row["license_file_json"])
|
||||||
conn.close()
|
conn.close()
|
||||||
return licensing.build_master_response(
|
return licensing.build_master_response(
|
||||||
@@ -2502,11 +2806,17 @@ def customers():
|
|||||||
log_action("customer.delete", str(customer_id))
|
log_action("customer.delete", str(customer_id))
|
||||||
flash("Kunde gelöscht.", "success")
|
flash("Kunde gelöscht.", "success")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
# Deckt alle drei Sub-Aktionen mit einem Aufruf ab -- der Report
|
||||||
|
# ist ohnehin immer ein voller Schnappschuss, ein zusaetzlicher
|
||||||
|
# Versand bei einer fehlgeschlagenen Validierung sendet einfach
|
||||||
|
# den unveraenderten Stand erneut (harmlos).
|
||||||
|
_report_to_master_async()
|
||||||
return redirect(url_for("customers"))
|
return redirect(url_for("customers"))
|
||||||
|
|
||||||
customers_rows = conn.execute("""
|
customers_rows = conn.execute("""
|
||||||
SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count,
|
SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count,
|
||||||
COUNT(DISTINCT licenses.id) AS license_count
|
COUNT(DISTINCT licenses.id) AS license_count,
|
||||||
|
MAX(CASE WHEN licenses.role='license_server' AND licenses.status='active' THEN 1 ELSE 0 END) AS is_sub_license_server
|
||||||
FROM license_customers
|
FROM license_customers
|
||||||
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
|
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
|
||||||
LEFT JOIN licenses ON licenses.customer_id = license_customers.id
|
LEFT JOIN licenses ON licenses.customer_id = license_customers.id
|
||||||
@@ -2521,6 +2831,85 @@ def customers():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/customers/<int:customer_id>")
|
||||||
|
@login_required
|
||||||
|
def customer_detail(customer_id):
|
||||||
|
"""Kunden-Detailseite -- zeigt Stammdaten sowie, falls dieser Kunde eine
|
||||||
|
aktive role='license_server'-Lizenz haelt (er also selbst ein
|
||||||
|
Sub-Lizenzserver ist), einen zusaetzlichen Abschnitt mit dessen
|
||||||
|
GEMELDETEN eigenen Kunden/Tickets/Lizenzen (siehe reported_sub_*-
|
||||||
|
Tabellen, befuellt durch _ingest_sub_report() bei jedem Heartbeat
|
||||||
|
dieses Sub-Lizenzservers). Rein informativ/read-only -- dieser Master
|
||||||
|
verwaltet die gemeldeten Datensaetze nicht selbst."""
|
||||||
|
if not current_user.can_manage_customers:
|
||||||
|
flash("Keine Berechtigung, Kunden anzusehen.", "danger")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
conn = get_db_connection()
|
||||||
|
customer = _customer_row(conn, customer_id)
|
||||||
|
if not customer:
|
||||||
|
conn.close()
|
||||||
|
flash("Kunde nicht gefunden.", "danger")
|
||||||
|
return redirect(url_for("customers"))
|
||||||
|
|
||||||
|
own_tickets = conn.execute(
|
||||||
|
"SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,)
|
||||||
|
).fetchall()
|
||||||
|
now = time.time()
|
||||||
|
tickets_view = []
|
||||||
|
for t in own_tickets:
|
||||||
|
d = dict(t)
|
||||||
|
d["modules_list"] = json.loads(d.get("modules") or "[]")
|
||||||
|
current = _latest_license_for_ticket(conn, t["id"])
|
||||||
|
d["license"] = _license_dict_for_display(current, now) if current else None
|
||||||
|
tickets_view.append(d)
|
||||||
|
|
||||||
|
# Ist dieser Kunde selbst ein (aktiver) Sub-Lizenzserver? Referenz fuer
|
||||||
|
# die reported_sub_*-Zeilen ist die license_id der role='license_server'-
|
||||||
|
# Lizenz, die dieser Kunde von UNS erhalten hat -- nicht seine eigene,
|
||||||
|
# bei ihm lokal laufende Lizenz.
|
||||||
|
sub_license_row = conn.execute(
|
||||||
|
"SELECT * FROM licenses WHERE customer_id=? AND role='license_server' AND status='active' "
|
||||||
|
"ORDER BY activated_at DESC LIMIT 1", (customer_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
reported_customers, reported_tickets_by_customer, reported_licenses_by_ticket = [], {}, {}
|
||||||
|
if sub_license_row:
|
||||||
|
source_license_id = sub_license_row["license_id"]
|
||||||
|
reported_customers = [
|
||||||
|
dict(r) for r in conn.execute(
|
||||||
|
"SELECT * FROM reported_sub_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE",
|
||||||
|
(source_license_id,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
for t in conn.execute(
|
||||||
|
"SELECT * FROM reported_sub_tickets WHERE source_license_id=? ORDER BY created_at DESC",
|
||||||
|
(source_license_id,),
|
||||||
|
).fetchall():
|
||||||
|
reported_tickets_by_customer.setdefault(t["remote_customer_ref"], []).append(dict(t))
|
||||||
|
for l in conn.execute(
|
||||||
|
"SELECT * FROM reported_sub_licenses WHERE source_license_id=? ORDER BY issued_at DESC",
|
||||||
|
(source_license_id,),
|
||||||
|
).fetchall():
|
||||||
|
# _license_dict_for_display() erwartet dieselben Spaltennamen
|
||||||
|
# (expires_at/lifetime/status/modules), die reported_sub_licenses
|
||||||
|
# bewusst 1:1 uebernimmt -- liefert damit automatisch dieselbe
|
||||||
|
# Ampelfarbe/Resttage-Berechnung wie fuer eigene Lizenzen.
|
||||||
|
reported_licenses_by_ticket.setdefault(l["remote_ticket_id"], []).append(
|
||||||
|
_license_dict_for_display(l, now)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template(
|
||||||
|
"customer_detail.html", customer=customer, tickets=tickets_view,
|
||||||
|
sub_license=dict(sub_license_row) if sub_license_row else None,
|
||||||
|
reported_customers=reported_customers,
|
||||||
|
reported_tickets_by_customer=reported_tickets_by_customer,
|
||||||
|
reported_licenses_by_ticket=reported_licenses_by_ticket,
|
||||||
|
type_labels=LICENSE_TYPE_LABELS,
|
||||||
|
can_edit=current_user.has_permission("customers.edit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/tickets/new", methods=["GET", "POST"])
|
@app.route("/tickets/new", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def ticket_new():
|
def ticket_new():
|
||||||
@@ -2528,7 +2917,7 @@ def ticket_new():
|
|||||||
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
|
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
|
||||||
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
|
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
|
||||||
Admin ODER Kunde per Self-Service)."""
|
Admin ODER Kunde per Self-Service)."""
|
||||||
if not current_user.has_permission("licenses.create"):
|
if not current_user.has_permission("tickets.create"):
|
||||||
flash("Keine Berechtigung, Tickets anzulegen.", "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()
|
||||||
@@ -2561,12 +2950,31 @@ def ticket_new():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for("ticket_new"))
|
return redirect(url_for("ticket_new"))
|
||||||
|
|
||||||
|
# role='license_server' darf NUR ein echter Admin auf einer
|
||||||
|
# tatsaechlichen "master"-Instanz waehlen -- fest einprogrammiert,
|
||||||
|
# nicht ueber ein delegierbares Recht (siehe
|
||||||
|
# can_issue_license_server_licenses()-Docstring). Jeder andere
|
||||||
|
# Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin)
|
||||||
|
# wird still auf 'customer' zurueckgestuft statt nur die UI
|
||||||
|
# auszublenden -- Verteidigung in der Tiefe.
|
||||||
|
role = request.form.get("role", "customer")
|
||||||
|
if role == "license_server" and not (current_user.is_admin and can_issue_license_server_licenses()):
|
||||||
|
flash("Keine Berechtigung, Lizenzserver-Lizenzen auszustellen.", "danger")
|
||||||
|
role = "customer"
|
||||||
|
if role not in licensing.LICENSE_ROLES or role == "master":
|
||||||
|
role = "customer"
|
||||||
|
|
||||||
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("ticket_new"))
|
return redirect(url_for("ticket_new"))
|
||||||
if license_type == "enterprise":
|
if role != "customer":
|
||||||
|
# Module (dhcp/fileshare/maintenance) sind ein reines
|
||||||
|
# TESM-Client-Konzept -- fuer eine Lizenzserver-Lizenz
|
||||||
|
# bedeutungslos.
|
||||||
|
modules = []
|
||||||
|
elif license_type == "enterprise":
|
||||||
modules = list(licensing.ALL_MODULES)
|
modules = list(licensing.ALL_MODULES)
|
||||||
elif license_type == "custom":
|
elif license_type == "custom":
|
||||||
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
|
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
|
||||||
@@ -2582,7 +2990,10 @@ def ticket_new():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
valid_days = 365
|
valid_days = 365
|
||||||
|
|
||||||
ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username, lifetime=lifetime)
|
ticket_id = _create_ticket(
|
||||||
|
conn, customer_id, license_type, modules, valid_days, current_user.username,
|
||||||
|
lifetime=lifetime, role=role,
|
||||||
|
)
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("Ticket angelegt.", "success")
|
flash("Ticket angelegt.", "success")
|
||||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||||
@@ -2593,13 +3004,14 @@ def ticket_new():
|
|||||||
"ticket_new.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,
|
||||||
preselect_customer_id=request.args.get("customer_id", type=int),
|
preselect_customer_id=request.args.get("customer_id", type=int),
|
||||||
|
can_issue_license_server=current_user.is_admin and can_issue_license_server_licenses(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
|
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def ticket_detail(ticket_id):
|
def ticket_detail(ticket_id):
|
||||||
if not current_user.can_manage_licenses:
|
if not current_user.can_manage_tickets:
|
||||||
flash("Keine Berechtigung.", "danger")
|
flash("Keine Berechtigung.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2610,10 +3022,14 @@ def ticket_detail(ticket_id):
|
|||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
if "issue_license" in request.form:
|
||||||
|
# Ausstellen erzeugt eine LIZENZ (nicht nur eine Ticket-Aenderung)
|
||||||
|
# -- bewusst weiterhin ueber das Lizenz-Recht gegated, nicht ueber
|
||||||
|
# tickets.edit, damit sich "Tickets verwalten" und "Lizenzen
|
||||||
|
# ausstellen" unabhaengig voneinander vergeben lassen.
|
||||||
if not current_user.has_permission("licenses.edit"):
|
if not current_user.has_permission("licenses.edit"):
|
||||||
flash("Keine Berechtigung.", "danger")
|
flash("Keine Berechtigung, Lizenzen auszustellen.", "danger")
|
||||||
elif "issue_license" in request.form:
|
elif not license_active():
|
||||||
if not license_active():
|
|
||||||
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
|
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
|
||||||
elif ticket["status"] != "open":
|
elif ticket["status"] != "open":
|
||||||
flash("Ticket ist gesperrt.", "danger")
|
flash("Ticket ist gesperrt.", "danger")
|
||||||
@@ -2621,11 +3037,24 @@ def ticket_detail(ticket_id):
|
|||||||
flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger")
|
flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger")
|
||||||
else:
|
else:
|
||||||
as_trial = request.form.get("issue_license") == "trial"
|
as_trial = request.form.get("issue_license") == "trial"
|
||||||
|
try:
|
||||||
license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial)
|
license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial)
|
||||||
|
except ValueError as exc:
|
||||||
|
# z.B. Trial-Override auf einem Lizenzserver-Ticket
|
||||||
|
# versucht (siehe _issue_license_from_ticket) -- im
|
||||||
|
# normalen UI-Fluss nicht erreichbar (Button wird für
|
||||||
|
# role != 'customer' gar nicht erst angezeigt), aber
|
||||||
|
# eine manipulierte Anfrage soll eine klare Meldung statt
|
||||||
|
# eines internen Serverfehlers bekommen.
|
||||||
|
flash(str(exc), "danger")
|
||||||
|
else:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("Lizenz ausgestellt.", "success")
|
flash("Lizenz ausgestellt.", "success")
|
||||||
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
|
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
|
||||||
elif "toggle_block" in request.form:
|
elif "toggle_block" in request.form:
|
||||||
|
if not current_user.has_permission("tickets.edit"):
|
||||||
|
flash("Keine Berechtigung, Tickets zu sperren/entsperren.", "danger")
|
||||||
|
else:
|
||||||
new_status = "blocked" if ticket["status"] == "open" else "open"
|
new_status = "blocked" if ticket["status"] == "open" else "open"
|
||||||
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
|
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -2654,7 +3083,8 @@ def ticket_detail(ticket_id):
|
|||||||
master_licensed=license_active(),
|
master_licensed=license_active(),
|
||||||
self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
|
self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
|
||||||
portal_request_url=_customer_facing_url("portal_request"),
|
portal_request_url=_customer_facing_url("portal_request"),
|
||||||
can_edit=current_user.has_permission("licenses.edit"),
|
can_edit_ticket=current_user.has_permission("tickets.edit"),
|
||||||
|
can_issue_license=current_user.has_permission("licenses.edit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2670,7 +3100,7 @@ def ticket_edit(ticket_id):
|
|||||||
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
|
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
|
||||||
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
|
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
|
||||||
erreichbar ist)."""
|
erreichbar ist)."""
|
||||||
if not current_user.has_permission("licenses.edit"):
|
if not current_user.has_permission("tickets.edit"):
|
||||||
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
|
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
|
||||||
return redirect(url_for("tickets_list"))
|
return redirect(url_for("tickets_list"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2710,6 +3140,7 @@ def ticket_edit(ticket_id):
|
|||||||
"ticket.update", customer["name"] if customer else ticket_id,
|
"ticket.update", customer["name"] if customer else ticket_id,
|
||||||
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}",
|
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}",
|
||||||
)
|
)
|
||||||
|
_report_to_master_async()
|
||||||
flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success")
|
flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success")
|
||||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
@@ -2723,7 +3154,7 @@ def ticket_delete(ticket_id):
|
|||||||
System verschwinden). Etwaige bereits widerrufene/deaktivierte
|
System verschwinden). Etwaige bereits widerrufene/deaktivierte
|
||||||
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
|
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
|
||||||
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
|
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
|
||||||
if not current_user.has_permission("licenses.edit"):
|
if not current_user.has_permission("tickets.edit"):
|
||||||
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
|
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
|
||||||
return redirect(url_for("tickets_list"))
|
return redirect(url_for("tickets_list"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2755,6 +3186,7 @@ def ticket_delete(ticket_id):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
log_action("ticket.delete", customer["name"] if customer else ticket_id)
|
log_action("ticket.delete", customer["name"] if customer else ticket_id)
|
||||||
|
_report_to_master_async()
|
||||||
flash("Ticket gelöscht.", "success")
|
flash("Ticket gelöscht.", "success")
|
||||||
return redirect(url_for("tickets_list"))
|
return redirect(url_for("tickets_list"))
|
||||||
|
|
||||||
@@ -2892,6 +3324,11 @@ def license_send_email(license_id):
|
|||||||
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 = ""
|
self_service_note = ""
|
||||||
|
if row["role"] == "customer":
|
||||||
|
# Self-Service-Link und Kundenportal-Hinweis sind reine
|
||||||
|
# Kunden-Konzepte -- fuer eine Lizenzserver-Lizenz irrelevant (der
|
||||||
|
# Self-Service-Link wuerde dort ohnehin mit 404 abgewiesen, siehe
|
||||||
|
# self_service()).
|
||||||
if row["ticket_id"]:
|
if row["ticket_id"]:
|
||||||
self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"])
|
self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"])
|
||||||
self_service_note = (
|
self_service_note = (
|
||||||
@@ -2904,10 +3341,11 @@ def license_send_email(license_id):
|
|||||||
f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
|
f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
|
||||||
)
|
)
|
||||||
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
|
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
|
||||||
|
subject_prefix = "Ihre TESM-Lizenzserver-Lizenz" if row["role"] == "license_server" else "Ihre TESM-Lizenz"
|
||||||
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"{subject_prefix} ({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: {valid_until}\n"
|
f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {valid_until}\n"
|
||||||
f"{self_service_note}",
|
f"{self_service_note}",
|
||||||
@@ -2965,6 +3403,7 @@ def license_delete(license_id):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}")
|
log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}")
|
||||||
|
_report_to_master_async()
|
||||||
flash("Lizenz gelöscht.", "success")
|
flash("Lizenz gelöscht.", "success")
|
||||||
if ticket_id:
|
if ticket_id:
|
||||||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||||||
@@ -2984,7 +3423,15 @@ def license_delete(license_id):
|
|||||||
def self_service(ticket_id):
|
def self_service(ticket_id):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
ticket = _ticket_row(conn, ticket_id)
|
ticket = _ticket_row(conn, ticket_id)
|
||||||
if not ticket:
|
if not ticket or ticket["role"] != "customer":
|
||||||
|
# Self-Service ist ein KUNDENWEG (unauthentifiziert, Zugriff allein
|
||||||
|
# ueber die unratbare Ticket-ID) -- Lizenzserver-Lizenzen werden
|
||||||
|
# ausschliesslich bewusst durch einen Admin auf der Ticket-
|
||||||
|
# Detailseite ausgestellt/verwaltet, nie ueber diesen Weg. Behandelt
|
||||||
|
# eine Lizenzserver-Ticket-ID hier bewusst wie "nicht gefunden",
|
||||||
|
# nicht wie "keine Berechtigung" -- sonst liesse sich allein aus der
|
||||||
|
# Fehlermeldung ablesen, dass eine geratene/erlangte ID zu einem
|
||||||
|
# existierenden Lizenzserver-Ticket gehoert.
|
||||||
conn.close()
|
conn.close()
|
||||||
abort(404)
|
abort(404)
|
||||||
customer = _customer_row(conn, ticket["customer_id"])
|
customer = _customer_row(conn, ticket["customer_id"])
|
||||||
@@ -3005,7 +3452,7 @@ def self_service(ticket_id):
|
|||||||
def self_service_download(ticket_id):
|
def self_service_download(ticket_id):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
ticket = _ticket_row(conn, ticket_id)
|
ticket = _ticket_row(conn, ticket_id)
|
||||||
if not ticket:
|
if not ticket or ticket["role"] != "customer":
|
||||||
conn.close()
|
conn.close()
|
||||||
abort(404)
|
abort(404)
|
||||||
current = _latest_license_for_ticket(conn, ticket_id)
|
current = _latest_license_for_ticket(conn, ticket_id)
|
||||||
@@ -3023,7 +3470,7 @@ def self_service_download(ticket_id):
|
|||||||
def self_service_revoke(ticket_id):
|
def self_service_revoke(ticket_id):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
ticket = _ticket_row(conn, ticket_id)
|
ticket = _ticket_row(conn, ticket_id)
|
||||||
if not ticket:
|
if not ticket or ticket["role"] != "customer":
|
||||||
conn.close()
|
conn.close()
|
||||||
abort(404)
|
abort(404)
|
||||||
current = _latest_license_for_ticket(conn, ticket_id)
|
current = _latest_license_for_ticket(conn, ticket_id)
|
||||||
@@ -3041,7 +3488,7 @@ def self_service_revoke(ticket_id):
|
|||||||
def self_service_issue(ticket_id):
|
def self_service_issue(ticket_id):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
ticket = _ticket_row(conn, ticket_id)
|
ticket = _ticket_row(conn, ticket_id)
|
||||||
if not ticket:
|
if not ticket or ticket["role"] != "customer":
|
||||||
conn.close()
|
conn.close()
|
||||||
abort(404)
|
abort(404)
|
||||||
if not license_active():
|
if not license_active():
|
||||||
@@ -3675,7 +4122,7 @@ def tickets_list():
|
|||||||
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
|
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
|
||||||
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
|
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
|
||||||
Navigation erreichbar."""
|
Navigation erreichbar."""
|
||||||
if not current_user.has_permission("licenses.view"):
|
if not current_user.has_permission("tickets.view"):
|
||||||
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
|
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -3708,8 +4155,8 @@ def tickets_list():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||||
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
|
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
|
||||||
can_create=current_user.has_permission("licenses.create"),
|
can_create=current_user.has_permission("tickets.create"),
|
||||||
can_edit=current_user.has_permission("licenses.edit"),
|
can_edit=current_user.has_permission("tickets.edit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4699,7 +5146,7 @@ def settings():
|
|||||||
@app.route("/settings/license")
|
@app.route("/settings/license")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_license():
|
def settings_license():
|
||||||
if not current_user.can_view_settings_system:
|
if not current_user.can_view_settings_license:
|
||||||
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
pending_raw = get_setting("license_pending_request", "")
|
pending_raw = get_setting("license_pending_request", "")
|
||||||
@@ -4722,7 +5169,7 @@ def settings_license():
|
|||||||
@app.route("/settings/license/upload", methods=["POST"])
|
@app.route("/settings/license/upload", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_upload():
|
def license_upload():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
uploaded = request.files.get("license_file")
|
uploaded = request.files.get("license_file")
|
||||||
@@ -4815,7 +5262,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
|
|||||||
@app.route("/settings/license/activate", methods=["POST"])
|
@app.route("/settings/license/activate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_activate():
|
def license_activate():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
if not _license_state.get("file") or not _license_state.get("valid"):
|
if not _license_state.get("file") or not _license_state.get("valid"):
|
||||||
@@ -4832,7 +5279,7 @@ def license_activate():
|
|||||||
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_activate_confirm():
|
def license_activate_confirm():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
@@ -4846,7 +5293,7 @@ def license_activate_confirm():
|
|||||||
@app.route("/settings/license/deactivate", methods=["POST"])
|
@app.route("/settings/license/deactivate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_deactivate():
|
def license_deactivate():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
if not _license_state.get("file") or not license_activated():
|
if not _license_state.get("file") or not license_activated():
|
||||||
@@ -4866,7 +5313,7 @@ def license_deactivate():
|
|||||||
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_deactivate_confirm():
|
def license_deactivate_confirm():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ CREATE TABLE IF NOT EXISTS license_tickets (
|
|||||||
valid_days INTEGER NOT NULL,
|
valid_days INTEGER NOT NULL,
|
||||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
role TEXT NOT NULL DEFAULT 'customer',
|
||||||
created_by TEXT,
|
created_by TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||||
@@ -179,6 +180,49 @@ CREATE TABLE IF NOT EXISTS portal_access_tokens (
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reported_sub_customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_license_id TEXT NOT NULL,
|
||||||
|
remote_customer_ref TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
contact_email TEXT, contact_phone TEXT, contact_person TEXT,
|
||||||
|
street TEXT, house_number TEXT, postal_code TEXT, city TEXT,
|
||||||
|
last_reported_at TEXT NOT NULL,
|
||||||
|
UNIQUE (source_license_id, remote_customer_ref)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reported_sub_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_license_id TEXT NOT NULL,
|
||||||
|
remote_ticket_id TEXT NOT NULL,
|
||||||
|
remote_customer_ref TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
|
||||||
|
valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer',
|
||||||
|
created_at TEXT,
|
||||||
|
last_reported_at TEXT NOT NULL,
|
||||||
|
UNIQUE (source_license_id, remote_ticket_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reported_sub_licenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_license_id TEXT NOT NULL,
|
||||||
|
remote_license_id TEXT NOT NULL,
|
||||||
|
remote_ticket_id TEXT,
|
||||||
|
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
|
||||||
|
issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT,
|
||||||
|
last_reported_at TEXT NOT NULL,
|
||||||
|
UNIQUE (source_license_id, remote_license_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)")
|
||||||
|
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)")
|
||||||
|
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)")
|
||||||
|
|
||||||
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
|
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
|
||||||
|
|
||||||
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
|
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ def main():
|
|||||||
master_public_key_b64=master_pub,
|
master_public_key_b64=master_pub,
|
||||||
master_endpoint="",
|
master_endpoint="",
|
||||||
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
|
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
|
||||||
|
# role="master": markiert dies als die Wurzel-Instanz -- NUR dieses
|
||||||
|
# Skript darf role="master" setzen (siehe LICENSE_ROLES in
|
||||||
|
# licensing.py). app.py bietet role="master" an keiner Stelle über
|
||||||
|
# die normale Ticket-/Ausstellungs-UI an; das ist die einzige Rolle,
|
||||||
|
# die selbst weitere role="license_server"-Lizenzen ausstellen darf.
|
||||||
|
role="master",
|
||||||
)
|
)
|
||||||
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
|
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
|
||||||
json.dump(license_file, f, indent=2)
|
json.dump(license_file, f, indent=2)
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
|
|||||||
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
|
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
|
||||||
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
|
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
|
||||||
|
|
||||||
|
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
|
||||||
|
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
|
||||||
|
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
|
||||||
|
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
|
||||||
|
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
|
||||||
|
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
|
||||||
|
# create_master_license.py erzeugt, NIE ueber die
|
||||||
|
# normale Ticket-/Ausstellungs-UI -- das waere sonst
|
||||||
|
# eine Moeglichkeit, sich selbst zur Wurzel zu
|
||||||
|
# erklaeren. Einzige Rolle, die weitere
|
||||||
|
# "license_server"-Lizenzen ausstellen darf.
|
||||||
|
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
|
||||||
|
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
|
||||||
|
# Darf alles, was ein Lizenzserver koennen soll
|
||||||
|
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
|
||||||
|
# ausstellen, Benutzer/Gruppen/Einstellungen
|
||||||
|
# verwalten) -- AUSSER selbst weitere
|
||||||
|
# "license_server"-Lizenzen ausstellen. Diese Sperre
|
||||||
|
# ist strukturell (haengt an der eigenen Rolle, nicht
|
||||||
|
# an einem vergebbaren Recht) und daher auch von einem
|
||||||
|
# lokalen Admin dieser Instanz nicht aufhebbar.
|
||||||
|
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
|
||||||
|
# unveraendertes Verhalten).
|
||||||
|
LICENSE_ROLES = ("master", "license_server", "customer")
|
||||||
|
|
||||||
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
|
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
|
||||||
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
|
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
|
||||||
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
|
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
|
||||||
@@ -120,13 +145,23 @@ LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
|
|||||||
|
|
||||||
def issue_license(*, customer, license_type, modules, valid_days,
|
def issue_license(*, customer, license_type, modules, valid_days,
|
||||||
master_private_key_b64, master_public_key_b64,
|
master_private_key_b64, master_public_key_b64,
|
||||||
master_endpoint, vendor, license_id=None, now=None, lifetime=False):
|
master_endpoint, vendor, license_id=None, now=None, lifetime=False,
|
||||||
|
role="customer"):
|
||||||
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
||||||
frischem Pro-Lizenz-Schlüsselpaar.
|
frischem Pro-Lizenz-Schlüsselpaar.
|
||||||
|
|
||||||
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
|
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
|
||||||
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
|
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
|
||||||
|
|
||||||
|
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
|
||||||
|
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
|
||||||
|
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
|
||||||
|
NUR von create_master_license.py verwendet werden (lokal, ohne diese
|
||||||
|
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
|
||||||
|
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
|
||||||
|
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
|
||||||
|
Option anbietet (siehe app.py can_issue_license_server_licenses()).
|
||||||
|
|
||||||
Rückgabe: (license_file, license_pubkey) -- license_file ist die
|
Rückgabe: (license_file, license_pubkey) -- license_file ist die
|
||||||
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
|
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
|
||||||
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
|
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
|
||||||
@@ -134,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
|
|||||||
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
|
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
|
||||||
if license_type not in LICENSE_TYPES:
|
if license_type not in LICENSE_TYPES:
|
||||||
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
|
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
|
||||||
|
if role not in LICENSE_ROLES:
|
||||||
|
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
|
||||||
unknown = set(modules) - set(ALL_MODULES)
|
unknown = set(modules) - set(ALL_MODULES)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
|
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
|
||||||
@@ -151,6 +188,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
|
|||||||
"master_pubkey": master_public_key_b64,
|
"master_pubkey": master_public_key_b64,
|
||||||
"master_endpoint": master_endpoint,
|
"master_endpoint": master_endpoint,
|
||||||
"vendor": vendor,
|
"vendor": vendor,
|
||||||
|
"role": role,
|
||||||
}
|
}
|
||||||
signature = sign_payload(payload, master_private_key_b64)
|
signature = sign_payload(payload, master_private_key_b64)
|
||||||
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
|
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
|
||||||
@@ -190,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
|
|||||||
"type": license_file.get("type"),
|
"type": license_file.get("type"),
|
||||||
"customer": license_file.get("customer"),
|
"customer": license_file.get("customer"),
|
||||||
"expires_at": license_file.get("expires_at"),
|
"expires_at": license_file.get("expires_at"),
|
||||||
|
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
|
||||||
|
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
|
||||||
|
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
|
||||||
|
# "license_server" (sonst koennte eine alte Datei rueckwirkend
|
||||||
|
# unbeabsichtigt privilegiert erscheinen).
|
||||||
|
"role": license_file.get("role", "customer"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -231,11 +275,24 @@ def system_fingerprint():
|
|||||||
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
|
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
|
||||||
# verhindert".
|
# verhindert".
|
||||||
|
|
||||||
def build_client_request(action, license_file, nonce=None) -> dict:
|
def build_client_request(action, license_file, nonce=None, report=None) -> dict:
|
||||||
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
|
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
|
||||||
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
|
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
|
||||||
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
|
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
|
||||||
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt."""
|
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.
|
||||||
|
|
||||||
|
report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht
|
||||||
|
(siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein
|
||||||
|
optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses
|
||||||
|
Sub-Lizenzservers, den der Master beim Empfang in seine
|
||||||
|
reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben.
|
||||||
|
Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht
|
||||||
|
ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine
|
||||||
|
"customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt
|
||||||
|
dann in der Payload wie bisher (keine Verhaltensänderung für alle
|
||||||
|
bestehenden Aufrufer). Wie jedes andere Feld wird auch report
|
||||||
|
mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann
|
||||||
|
keine gefälschten Berichte unterschieben."""
|
||||||
if action not in ("activate", "deactivate", "heartbeat"):
|
if action not in ("activate", "deactivate", "heartbeat"):
|
||||||
raise ValueError(f"Unbekannte Aktion: {action!r}")
|
raise ValueError(f"Unbekannte Aktion: {action!r}")
|
||||||
payload = {
|
payload = {
|
||||||
@@ -252,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict:
|
|||||||
"nonce": nonce or uuid.uuid4().hex,
|
"nonce": nonce or uuid.uuid4().hex,
|
||||||
"timestamp": _iso(time.time()),
|
"timestamp": _iso(time.time()),
|
||||||
}
|
}
|
||||||
|
if report is not None:
|
||||||
|
payload["report"] = report
|
||||||
signature = sign_payload(payload, license_file["license_privkey"])
|
signature = sign_payload(payload, license_file["license_privkey"])
|
||||||
return {**payload, "signature": signature}
|
return {**payload, "signature": signature}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "customers" %}
|
||||||
|
{% block page_title %}Kunde{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ customer.name }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">
|
||||||
|
{{ customer.name }}
|
||||||
|
{% if sub_license %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('customers') }}" class="btn btn-secondary">← Alle Kunden</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div><h2 style="font-size:16px;">Stammdaten</h2></div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a href="{{ url_for('customers') }}" class="btn btn-sm btn-secondary">Bearbeiten</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>E-Mail</label><input type="text" value="{{ customer.contact_email or '—' }}" disabled></div>
|
||||||
|
<div class="field"><label>Telefon</label><input type="text" value="{{ customer.contact_phone or '—' }}" disabled></div>
|
||||||
|
<div class="field"><label>Ansprechpartner</label><input type="text" value="{{ customer.contact_person or '—' }}" disabled></div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Anschrift</label>
|
||||||
|
<input type="text" value="{{ [customer.street, customer.house_number]|select|join(' ') or '—' }}{{ ', ' ~ [customer.postal_code, customer.city]|select|join(' ') if customer.postal_code or customer.city else '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% if customer.notes %}
|
||||||
|
<div class="field"><label>Notizen</label><textarea disabled rows="3">{{ customer.notes }}</textarea></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div><h2 style="font-size:16px;">Eigene Tickets bei uns</h2></div>
|
||||||
|
<a href="{{ url_for('ticket_new') }}?customer_id={{ customer.id }}" class="btn btn-sm btn-secondary">Neues Ticket</a>
|
||||||
|
</div>
|
||||||
|
{% if tickets %}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Typ</th><th>Rolle</th><th>Ticket</th><th>Lizenz-Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tickets %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||||
|
<td>{% if t.role == 'license_server' %}<span class="pill admin">Lizenzserver</span>{% else %}Kunde{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}" class="pill {{ 'online' if t.status == 'open' else 'offline' }}">
|
||||||
|
{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if t.license %}
|
||||||
|
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
|
||||||
|
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
|
||||||
|
</span>
|
||||||
|
{% else %}<span class="text-faint">Noch keine Lizenz</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Noch keine Tickets für diesen Kunden.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sub_license %}
|
||||||
|
<div class="card card-pad" style="margin-top:20px;">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Von diesem Lizenzserver verwaltete Kunden</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Gemeldet vom Sub-Lizenzserver selbst bei jedem Heartbeat {{ hi.hint_icon("Rein informativ -- diese Daten werden nicht von uns verwaltet, sondern vom Sub-Lizenzserver selbst bei jedem Heartbeat (alle 6h, zusaetzlich sofort nach jeder dortigen Aenderung) als vollstaendiger Schnappschuss gemeldet und hier nur angezeigt.", "Gemeldete Daten") }}
|
||||||
|
{% if sub_license.last_heartbeat_at %}— zuletzt {{ sub_license.last_heartbeat_at }}{% else %}— noch kein Heartbeat empfangen{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if reported_customers %}
|
||||||
|
{% for rc in reported_customers %}
|
||||||
|
<div class="card card-pad" style="margin-bottom:12px; background:var(--bg-elevated);">
|
||||||
|
<div class="section-head" style="margin-bottom:10px;">
|
||||||
|
<div><strong>{{ rc.name }}</strong> <span class="text-dim" style="font-size:12px;">{{ rc.contact_email or '' }}</span></div>
|
||||||
|
</div>
|
||||||
|
{% set rtickets = reported_tickets_by_customer.get(rc.remote_customer_ref, []) %}
|
||||||
|
{% if rtickets %}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Typ</th><th>Laufzeit</th><th>Ticket-Status</th><th>Lizenz-Status</th><th>Ablauf</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rt in rtickets %}
|
||||||
|
{% set rlicenses = reported_licenses_by_ticket.get(rt.remote_ticket_id, []) %}
|
||||||
|
{% set rl = rlicenses[0] if rlicenses else none %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ type_labels.get(rt.type, rt.type) }}</td>
|
||||||
|
<td class="text-dim">{{ 'Lifetime' if rt.lifetime else (rt.valid_days ~ ' Tage' if rt.valid_days else '—') }}</td>
|
||||||
|
<td><span class="pill {{ 'online' if rt.status == 'open' else 'offline' }}">{{ 'Offen' if rt.status == 'open' else 'Gesperrt' }}</span></td>
|
||||||
|
{% if rl %}
|
||||||
|
<td>
|
||||||
|
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(rl.status, 'disabled') }}">
|
||||||
|
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(rl.status, rl.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-dim">
|
||||||
|
{% if rl.is_lifetime %}Lifetime
|
||||||
|
{% elif rl.days_left is not none %}
|
||||||
|
<span style="{{ 'color:var(--danger);' if rl.color == 'red' else ('color:var(--warning);' if rl.color == 'orange' else '') }}">{{ rl.days_left }} Tage</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td class="text-faint" colspan="2">Noch keine Lizenz</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12px;">Noch keine Tickets gemeldet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Dieser Lizenzserver hat noch keine eigenen Kunden gemeldet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -37,7 +37,10 @@
|
|||||||
{% for c in customers %}
|
{% for c in customers %}
|
||||||
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}" data-sort-licenses="{{ c.license_count }}">
|
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}" data-sort-licenses="{{ c.license_count }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">{{ c.name }}</td>
|
<td class="cell-name">
|
||||||
|
<a href="{{ url_for('customer_detail', customer_id=c.id) }}">{{ c.name }}</a>
|
||||||
|
{% if c.is_sub_license_server %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-dim">
|
<td class="text-dim">
|
||||||
{% if c.street or c.city %}
|
{% if c.street or c.city %}
|
||||||
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
|
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
|
||||||
|
|||||||
@@ -45,7 +45,10 @@
|
|||||||
data-sort-issued="{{ l.issued_at }}" data-sort-expires="{{ l.expires_at }}"
|
data-sort-issued="{{ l.issued_at }}" data-sort-expires="{{ l.expires_at }}"
|
||||||
data-sort-status="{{ l.status }}" data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
|
data-sort-status="{{ l.status }}" data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">{{ l.customer_name }}</td>
|
<td class="cell-name">
|
||||||
|
{{ l.customer_name }}
|
||||||
|
{% if l.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td class="mono text-dim">{{ l.license_id[:8] }}…</td>
|
<td class="mono text-dim">{{ l.license_id[:8] }}…</td>
|
||||||
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
||||||
<td class="text-dim">{{ l.issued_at[:10] }}</td>
|
<td class="text-dim">{{ l.issued_at[:10] }}</td>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "settings_license" %}
|
{% set active_page = "settings_license" %}
|
||||||
{% set can_edit = current_user.has_permission('settings_system.edit') %}
|
{% set can_edit = current_user.has_permission('settings_license.edit') %}
|
||||||
{% block page_title %}Lizenz{% endblock %}
|
{% block page_title %}Lizenz{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,29 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div><h2 style="font-size:16px;">Ticket</h2></div>
|
<div><h2 style="font-size:16px;">Ticket</h2></div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if ticket.role == 'license_server' %}
|
||||||
|
<span class="pill admin">Lizenzserver</span>
|
||||||
|
{% endif %}
|
||||||
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
|
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
|
||||||
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
|
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if ticket.role == 'license_server' %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
|
||||||
|
Lizenzserver-Ticket: eine daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Sub-Lizenzserver
|
||||||
|
(darf eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten) — außer selbst weitere
|
||||||
|
Lizenzserver-Lizenzen ausstellen. Kein Self-Service-Zugang für diesen Ticket-Typ.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></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>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div>
|
||||||
|
{% if ticket.role == 'customer' %}
|
||||||
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
|
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
|
||||||
|
{% endif %}
|
||||||
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
|
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
|
||||||
|
{% if ticket.role == 'customer' %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
|
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
|
||||||
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
|
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
|
||||||
@@ -26,8 +41,9 @@
|
|||||||
Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}:
|
Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}:
|
||||||
der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
|
der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit_ticket %}
|
||||||
<div class="flex gap-2" style="margin-top:14px;">
|
<div class="flex gap-2" style="margin-top:14px;">
|
||||||
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
|
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
<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>
|
||||||
@@ -69,7 +85,7 @@
|
|||||||
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
|
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
|
||||||
eine neue erstellt werden.
|
eine neue erstellt werden.
|
||||||
</p>
|
</p>
|
||||||
{% elif can_edit %}
|
{% elif can_issue_license %}
|
||||||
<form method="post" style="margin-bottom:10px;">
|
<form method="post" style="margin-bottom:10px;">
|
||||||
<input type="hidden" name="issue_license" value="regular">
|
<input type="hidden" name="issue_license" value="regular">
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
@@ -77,11 +93,13 @@
|
|||||||
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
|
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if ticket.role == 'customer' %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="issue_license" value="trial">
|
<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>
|
<button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-pad" style="grid-column: 1 / -1;">
|
<div class="card card-pad" style="grid-column: 1 / -1;">
|
||||||
@@ -112,7 +130,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit_ticket %}
|
||||||
<div class="modal-overlay" id="editTicketModal">
|
<div class="modal-overlay" id="editTicketModal">
|
||||||
<div class="modal" style="max-width:520px;">
|
<div class="modal" style="max-width:520px;">
|
||||||
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">
|
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">
|
||||||
|
|||||||
@@ -35,6 +35,15 @@
|
|||||||
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
|
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if can_issue_license_server %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="role" value="license_server" id="role_license_server" onchange="toggleLicenseServerRole()">
|
||||||
|
Dies ist eine Lizenzserver-Lizenz {{ hi.hint_icon("Für einen Sub-Lizenzserver, nicht für eine gewöhnliche TESM-Kundeninstanz. Die daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Lizenzserver -- eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten -- außer selbst weitere Lizenzserver-Lizenzen ausstellen (das bleibt strukturell dieser Instanz vorbehalten). Kein Self-Service-Zugang, Module sind bedeutungslos.", "Lizenzserver-Lizenz") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<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. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "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>
|
||||||
@@ -90,7 +99,12 @@ function toggleNewCustomerFields() {
|
|||||||
}
|
}
|
||||||
function toggleModuleFields() {
|
function toggleModuleFields() {
|
||||||
const type = document.getElementById("license_type").value;
|
const type = document.getElementById("license_type").value;
|
||||||
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
|
const roleBox = document.getElementById("role_license_server");
|
||||||
|
const isLicenseServer = roleBox && roleBox.checked;
|
||||||
|
// Module sind bei einer Lizenzserver-Lizenz bedeutungslos (siehe
|
||||||
|
// ticket_new() in app.py, erzwingt dort ohnehin modules=[]) -- Feld
|
||||||
|
// bleibt unabhaengig vom Lizenztyp ausgeblendet.
|
||||||
|
document.getElementById("moduleFields").classList.toggle("hidden", isLicenseServer || type !== "custom");
|
||||||
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
|
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
|
||||||
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
|
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
|
||||||
// in app.py), hier nur als klares UI-Signal ausgegraut.
|
// in app.py), hier nur als klares UI-Signal ausgegraut.
|
||||||
@@ -101,6 +115,9 @@ function toggleModuleFields() {
|
|||||||
toggleLifetime();
|
toggleLifetime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function toggleLicenseServerRole() {
|
||||||
|
toggleModuleFields();
|
||||||
|
}
|
||||||
function toggleLifetime() {
|
function toggleLifetime() {
|
||||||
const isLifetime = document.getElementById("lifetime").checked;
|
const isLifetime = document.getElementById("lifetime").checked;
|
||||||
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
|
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
|
||||||
|
|||||||
@@ -45,7 +45,10 @@
|
|||||||
data-sort-status="{{ t.license.status 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 '' }}">
|
data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">{{ t.customer_name }}</td>
|
<td class="cell-name">
|
||||||
|
{{ t.customer_name }}
|
||||||
|
{% if t.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||||
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
|
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
|
||||||
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>
|
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user