Lizenzserver 2.8.12: Ticket als Anspruch, Lizenz als Urkunde daraus

This commit is contained in:
alientim committed 2026-09-16 22:00:59 +02:00
1 parent b984e22074
commit 641ea22b3c
8 files changed
+250 -22

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.8.11" version = "2.8.12"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -18,6 +18,7 @@ from .common import (
master_endpoint, master_endpoint,
module_labels, module_labels,
role_labels, role_labels,
signing_key,
type_labels, type_labels,
) )
@@ -131,7 +132,10 @@ def detail(ticket_id: str):
ticket=ticket, ticket=ticket,
ticket_modules=json.loads(str(ticket["modules"] or "[]")), ticket_modules=json.loads(str(ticket["modules"] or "[]")),
licenses=licenses, licenses=licenses,
# Zwei Spuren, zwei Auskuenfte: eine laufende Testlizenz darf die
# richtige Ausstellung nicht sperren und umgekehrt.
has_open_license=issuance.ticket_has_open_license(conn, ticket_id), has_open_license=issuance.ticket_has_open_license(conn, ticket_id),
has_open_trial=issuance.ticket_has_open_license(conn, ticket_id, trial=True),
modules=lic.ALL_MODULES, modules=lic.ALL_MODULES,
module_labels=module_labels(), module_labels=module_labels(),
types=lic.LICENSE_TYPES, types=lic.LICENSE_TYPES,
@@ -166,6 +170,20 @@ def update(ticket_id: str):
lifetime=request.form.get("lifetime") == "1", lifetime=request.form.get("lifetime") == "1",
notes=request.form.get("notes", ""), notes=request.form.get("notes", ""),
) )
# Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus. Wird der
# Anspruch laenger, muss die Urkunde nachziehen -- sonst stuende die
# Vereinbarung an einer Stelle und gaelte an einer anderen, und der
# Kunde merkte es an dem Tag, an dem seine Installation stehenbleibt.
#
# Nur verlaengern, nie verkuerzen: eine ausgestellte und signierte
# Zusage einseitig zu beschneiden waere etwas anderes als eine
# Korrektur. Eine Verkuerzung wirkt auf die naechste Ausstellung.
nachgezogen = None
if changes:
with transaction():
nachgezogen = issuance.lizenz_dem_ticket_nachziehen(
conn, ticket_id, master_private_key=signing_key().private_key
)
if changes: if changes:
log_event( log_event(
"ticket.updated", "ticket.updated",
@@ -175,12 +193,25 @@ def update(ticket_id: str):
scope="ticket", scope="ticket",
) )
reporting.report_soon(core()) reporting.report_soon(core())
flash( if nachgezogen is not None:
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung." log_event(
if changes "license.resigned",
else "Keine Änderungen.", target=str(nachgezogen["license_id"])[:12],
"success" if changes else "info", detail=f"Revision {nachgezogen['seq']}: Laufzeit dem Ticket angeglichen",
) severity="notice",
)
flash(
"Gespeichert. Die laufende Lizenz wurde mitverlängert (Revision "
f"{nachgezogen['seq']}) – der Client erhält sie beim nächsten Heartbeat.",
"success",
)
else:
flash(
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
if changes
else "Keine Änderungen.",
"success" if changes else "info",
)
except (issuance.IssuanceError, ValueError) as exc: except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id)) return redirect(url_for("tickets.detail", ticket_id=ticket_id))
+26 -6
View File
@@ -105,12 +105,17 @@ CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id);
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status); CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint); CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint);
-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im -- Hoechstens eine offene Lizenz **je Art** pro Ticket -- auf Datenbankebene,
-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber -- nicht nur im Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei
-- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die -- Anfragen) sauber abgefangen: der verlierende INSERT scheitert und der
-- bereits erzeugte Lizenz zurueck. -- Aufrufer gibt die bereits erzeugte Lizenz zurueck.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket --
ON licenses(ticket_id) WHERE status IN ('issued','active'); -- "Je Art", weil ein Ticket beides tragen darf: eine Testlizenz zum Ansehen
-- und die richtige Lizenz. Vorher war es ein Index ueber ticket_id allein --
-- und damit schloss ein dreissigtaegiger Test die Ausstellung aus.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art
ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END)
WHERE status IN ('issued','active');
-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay. -- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay.
CREATE TABLE IF NOT EXISTS license_nonces ( CREATE TABLE IF NOT EXISTS license_nonces (
@@ -295,9 +300,24 @@ def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen) rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen)
#: Ein Ticket darf eine Testlizenz **und** eine Volllizenz tragen.
#:
#: Der alte Index stand ueber ``ticket_id`` allein: sobald irgendeine Lizenz
#: dieses Tickets lief, ging keine zweite mehr. Damit schloss ein
#: dreissigtaegiger Blick ins Produkt die richtige Ausstellung aus -- und wer
#: zuerst ausstellte, konnte nichts mehr testen lassen. Die Regel bleibt, sie
#: zaehlt nur die Art mit.
V5 = """
DROP INDEX IF EXISTS idx_one_open_license_per_ticket;
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art
ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END)
WHERE status IN ('issued','active');
"""
LICENSE_MIGRATIONS: tuple[Migration, ...] = ( LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True), Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(version=2, name="revision-timestamp", sql=V2), Migration(version=2, name="revision-timestamp", sql=V2),
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen), Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
Migration(version=5, name="testlizenz-neben-volllizenz", sql=V5),
) )
@@ -7,7 +7,7 @@ import re
import secrets import secrets
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Iterable, Sequence from typing import Any, Iterable, Sequence
import tesm_licensing as lic import tesm_licensing as lic
@@ -16,6 +16,28 @@ from tesm_core.db import Database
TICKET_TYPES = lic.LICENSE_TYPES TICKET_TYPES = lic.LICENSE_TYPES
TICKET_STATES = ("open", "blocked") TICKET_STATES = ("open", "blocked")
#: Wann ist ein Ticket wirklich offen?
#:
#: Ein Ticket ist ein **Anspruch** auf eine Lizenz. Ist sie ausgestellt und
#: gilt sie, ist der Anspruch eingeloest -- offen ist dann nichts mehr. Die
#: Kachel auf der Startseite zaehlte trotzdem jedes Ticket mit ``status='open'``
#: und meldete "3 offene Tickets", waehrend zu allen dreien laengst eine aktive
#: Lizenz gehoerte. Wer danach sucht, sucht umsonst.
#:
#: Abgeleitet statt gespeichert, und das ist Absicht: wird eine Lizenz spaeter
#: widerrufen, ist der Anspruch von selbst wieder offen -- genau dann soll neu
#: ausgestellt werden. Ein dritter Ticketzustand muesste an derselben Stelle
#: von Hand zurueckgedreht werden, und wer das einmal vergisst, hat ein Ticket,
#: das erledigt aussieht und keines ist.
#: Eine **Testlizenz zaehlt nicht**: sie ist ein Blick ins Produkt, kein
#: eingeloester Anspruch. Jedes Ticket darf eine laufen lassen und bleibt
#: trotzdem offen -- sonst muesste man sich zwischen "ausprobieren" und
#: "bekommen" entscheiden.
OHNE_GUELTIGE_LIZENZ = (
"NOT EXISTS (SELECT 1 FROM licenses l "
"WHERE l.ticket_id = t.id AND l.status IN ('issued','active') AND l.type <> 'trial')"
)
LICENSE_STATES = ("issued", "active", "deactivated", "revoked") LICENSE_STATES = ("issued", "active", "deactivated", "revoked")
#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in #: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in
@@ -321,11 +343,22 @@ def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) ->
) )
def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool: def ticket_has_open_license(
conn: sqlite3.Connection, ticket_id: str, *, trial: bool = False
) -> bool:
"""Laeuft zu diesem Ticket schon eine Lizenz -- und zwar dieser Art?
Zwei Spuren, absichtlich getrennt: **eine** Testlizenz und **eine**
Volllizenz je Ticket. Vorher war es eine gemeinsame Sperre, und damit
schloss ein dreissigtaegiger Blick ins Produkt die richtige Ausstellung
aus -- oder umgekehrt. Wer testen laesst, will danach ausstellen.
"""
vergleich = "=" if trial else "<>"
return ( return (
Database.value( Database.value(
conn, conn,
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')", "SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active') "
f"AND type {vergleich} 'trial'",
(ticket_id,), (ticket_id,),
default=0, default=0,
) )
@@ -333,8 +366,15 @@ def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
) > 0 ) > 0
def ticket_has_any_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
"""Irgendeine laufende Lizenz -- Test oder Voll. Fuers Loeschen zaehlt beides."""
return ticket_has_open_license(conn, ticket_id) or ticket_has_open_license(
conn, ticket_id, trial=True
)
def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None: def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None:
if ticket_has_open_license(conn, ticket_id): if ticket_has_any_open_license(conn, ticket_id):
raise IssuanceError( raise IssuanceError(
"Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen." "Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen."
) )
@@ -431,6 +471,17 @@ def issue_from_ticket(
"Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver." "Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
) )
# Zwei Spuren je Ticket: eine Testlizenz **und** eine Volllizenz. Vorher
# sperrten sie sich gegenseitig -- ein dreissigtaegiger Blick ins Produkt
# schloss die richtige Ausstellung aus, und wer zuerst ausstellte, konnte
# nichts mehr testen lassen.
if ticket_has_open_license(conn, ticket_id, trial=as_trial):
raise IssuanceError(
"Für dieses Ticket läuft bereits eine Testlizenz."
if as_trial
else "Für dieses Ticket läuft bereits eine Lizenz."
)
if as_trial: if as_trial:
license_type = "trial" license_type = "trial"
modules = sorted(lic.ALL_MODULES) modules = sorted(lic.ALL_MODULES)
@@ -507,10 +558,11 @@ def issue_from_ticket(
# Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein # Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein
# zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz # zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz
# zurueck statt einen Fehler zu zeigen. # zurueck statt einen Fehler zu zeigen.
vergleich = "=" if as_trial else "<>"
existing = Database.one( existing = Database.one(
conn, conn,
"SELECT license_id, license_document FROM licenses " "SELECT license_id, license_document FROM licenses "
"WHERE ticket_id=? AND status IN ('issued','active')", f"WHERE ticket_id=? AND status IN ('issued','active') AND type {vergleich} 'trial'",
(ticket_id,), (ticket_id,),
) )
if existing is None: if existing is None:
@@ -538,6 +590,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
row = get_license(conn, license_id) row = get_license(conn, license_id)
if row is None: if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.") raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"])) document = json.loads(str(row["license_document"]))
bundle: dict[str, Any] = { bundle: dict[str, Any] = {
"kind": "tesm-license-bundle", "kind": "tesm-license-bundle",
@@ -585,12 +638,44 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,)) conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
def ticket_grenze(conn: sqlite3.Connection, license_id: str) -> str | None:
"""Bis wann darf diese Lizenz laufen? ``None`` heisst: unbegrenzt.
Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus -- und eine
Urkunde darf nicht mehr zusagen als der Anspruch hergibt. Vorher liess
sich eine Lizenz aus einem 25-Tage-Ticket beliebig weit verlaengern, ohne
dass am Ticket etwas davon zu sehen war: die Vereinbarung stand dann an
einer Stelle und galt an einer anderen.
Gerechnet wird ab der **Ausstellung** der Lizenz, nicht ab heute: wer ein
Ticket ueber 90 Tage hat, bekommt 90 Tage ab Ausstellung, nicht 90 weitere.
``None`` bei unbefristetem Ticket -- und auch dann, wenn zur Lizenz gar
kein Ticket gehoert (Altbestand, Wurzelinstanz): wo es keinen Anspruch
gibt, gibt es nichts zu begrenzen.
"""
row = get_license(conn, license_id)
if row is None or not row["ticket_id"]:
return None
ticket = get_ticket(conn, str(row["ticket_id"]))
if ticket is None or bool(ticket["lifetime"]):
return None
# Eine Testlizenz lebt nach ihren eigenen Regeln und nicht nach dem Ticket.
if str(row["type"]) == "trial":
return None
document = json.loads(str(row["license_document"]))
return lic.iso(
lic.parse_iso(str(document["issued_at"])) + timedelta(days=int(ticket["valid_days"]))
)
def resign_license( def resign_license(
conn: sqlite3.Connection, conn: sqlite3.Connection,
license_id: str, license_id: str,
*, *,
master_private_key: str, master_private_key: str,
changes: dict[str, Any], changes: dict[str, Any],
ueber_ticket: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -600,6 +685,18 @@ def resign_license(
row = get_license(conn, license_id) row = get_license(conn, license_id)
if row is None: if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.") raise IssuanceError("Diese Lizenz existiert nicht.")
# Geprueft wird hier und nicht in der Route: es ist die eine Stelle, durch
# die jede Neusignatur laeuft -- auch die aus einer Ticketverlaengerung,
# die deshalb ausdruecklich vorbeidarf (``ueber_ticket``).
grenze = ticket_grenze(conn, license_id)
ziel = str(changes.get("expires_at") or "")
if grenze and ziel and not ueber_ticket and ziel > grenze:
raise IssuanceError(
"So lange deckt das Ticket diese Lizenz nicht: es reicht bis "
f"{lic.parse_iso(grenze).strftime('%d.%m.%Y')}. Verlängern Sie zuerst das "
"Ticket -- die Verlängerung wirkt dann auf diese Lizenz."
)
document = json.loads(str(row["license_document"])) document = json.loads(str(row["license_document"]))
updated = licsrv.resign_license( updated = licsrv.resign_license(
document, master_private_key_b64=master_private_key, changes=changes document, master_private_key_b64=master_private_key, changes=changes
@@ -717,6 +814,56 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
} }
def lizenz_dem_ticket_nachziehen(
conn: sqlite3.Connection, ticket_id: str, *, master_private_key: str
) -> dict[str, Any] | None:
"""Zieht die laufende Lizenz eines Tickets auf dessen neue Laufzeit nach.
Gibt die neue Revision zurueck, oder ``None``, wenn es nichts zu tun gab.
**Nur verlaengern, nie verkuerzen.** Eine ausgestellte und signierte
Urkunde nachtraeglich zu beschneiden waere eine Zusage, die man einseitig
zurueckzieht -- und der Kunde erfuehre es erst, wenn seine Installation
stehenbleibt. Wird ein Ticket verkuerzt, gilt das fuer die naechste
Ausstellung.
"""
offene = Database.one(
conn,
"SELECT license_id FROM licenses WHERE ticket_id=? AND status IN ('issued','active') "
"AND type <> 'trial' LIMIT 1",
(ticket_id,),
)
if offene is None:
return None
license_id = str(offene["license_id"])
grenze = ticket_grenze(conn, license_id)
if grenze is None:
# Das Ticket ist unbefristet geworden -- dann auch die Lizenz.
row = get_license(conn, license_id)
if row is None or bool(row["lifetime"]):
return None
ticket = get_ticket(conn, ticket_id)
if ticket is None or not bool(ticket["lifetime"]):
return None
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes={"lifetime": True},
ueber_ticket=True,
)
row = get_license(conn, license_id)
if row is None or bool(row["lifetime"]) or str(row["expires_at"]) >= grenze:
return None
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes={"expires_at": grenze},
ueber_ticket=True,
)
def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]: def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
rows = list_licenses(conn) rows = list_licenses(conn)
buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []} buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []}
@@ -734,7 +881,21 @@ def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
), ),
"tickets_open": int( "tickets_open": int(
Database.value( Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0 conn,
"SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' "
f"AND {OHNE_GUELTIGE_LIZENZ}",
default=0,
)
or 0
),
# Zur Einordnung unter der Kachel: was schon erledigt ist, soll man
# sehen, ohne die Liste zu oeffnen.
"tickets_redeemed": int(
Database.value(
conn,
"SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' "
f"AND NOT {OHNE_GUELTIGE_LIZENZ}",
default=0,
) )
or 0 or 0
), ),
@@ -16,7 +16,13 @@
{% else %} {% else %}
<div class="grid grid--4"> <div class="grid grid--4">
{{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }} {{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }}
{{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }} {# "Offen" heisst: noch nicht eingeloest. Ein Ticket, zu dem eine gueltige
Lizenz gehoert, ist erledigt -- es hier mitzuzaehlen schickte jemanden
auf die Suche nach Arbeit, die es nicht gibt. #}
{{ stat('Offene Tickets', summary.counts.tickets_open,
hint_text=(summary.counts.tickets_redeemed ~ ' eingelöst')
if summary.counts.tickets_redeemed else '',
href=url_for('tickets.overview')) }}
{{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok', {{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok',
href=url_for('licenses.overview', status='active')) }} href=url_for('licenses.overview', status='active')) }}
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted', {{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
@@ -101,6 +101,8 @@
</div> </div>
</section> </section>
{# Die Laufzeit haengt am Ticket: weiter als dessen Anspruch reicht, geht
sie nicht. Das steht hier, damit es nicht erst die Fehlermeldung sagt. #}
{% if current_user.has('license_renew.execute') and license.status != 'revoked' %} {% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
@@ -36,6 +36,8 @@
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst. Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
{% else %} {% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets. Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
Die Laufzeit kommt aus dem Ticket und kann später nur mit ihm
verlängert werden.
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -57,8 +59,11 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="as_trial" value="1"> <input type="hidden" name="as_trial" value="1">
<button class="btn" type="submit" <button class="btn" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %} {# Eine laufende **Test**lizenz sperrt diesen Knopf, eine
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert." Volllizenz nicht: ansehen und bekommen sind zweierlei,
und ein Ticket darf beides tragen. #}
{% if has_open_trial or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket bleibt unverändert und offen -- eine Testlizenz löst es nicht ein."
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen" data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary"> data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module) Als Testlizenz ({{ trial_days }} Tage, alle Module)
@@ -62,7 +62,10 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
{% else %}{{ ticket.valid_days }} Tage{% endif %} {% else %}{{ ticket.valid_days }} Tage{% endif %}
</td> </td>
<td> <td>
{# Dieselbe Rechnung wie auf der Startseite, sonst widersprechen
sich die beiden Seiten: dort "0 offen", hier dreimal "offen". #}
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% elif ticket.open_license_id %}{{ pill('eingelöst', '') }}
{% else %}{{ pill('offen', 'online') }}{% endif %} {% else %}{{ pill('offen', 'online') }}{% endif %}
</td> </td>
<td class="fs-sm"> <td class="fs-sm">