Lizenzserver 2.8.12: Ticket als Anspruch, Lizenz als Urkunde daraus
This commit is contained in:
1 parent
b984e22074
commit
641ea22b3c
8 files changed
+250
-22
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-license"
|
||||
version = "2.8.11"
|
||||
version = "2.8.12"
|
||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -18,6 +18,7 @@ from .common import (
|
||||
master_endpoint,
|
||||
module_labels,
|
||||
role_labels,
|
||||
signing_key,
|
||||
type_labels,
|
||||
)
|
||||
|
||||
@@ -131,7 +132,10 @@ def detail(ticket_id: str):
|
||||
ticket=ticket,
|
||||
ticket_modules=json.loads(str(ticket["modules"] or "[]")),
|
||||
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_trial=issuance.ticket_has_open_license(conn, ticket_id, trial=True),
|
||||
modules=lic.ALL_MODULES,
|
||||
module_labels=module_labels(),
|
||||
types=lic.LICENSE_TYPES,
|
||||
@@ -166,6 +170,20 @@ def update(ticket_id: str):
|
||||
lifetime=request.form.get("lifetime") == "1",
|
||||
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:
|
||||
log_event(
|
||||
"ticket.updated",
|
||||
@@ -175,12 +193,25 @@ def update(ticket_id: str):
|
||||
scope="ticket",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
|
||||
if changes
|
||||
else "Keine Änderungen.",
|
||||
"success" if changes else "info",
|
||||
)
|
||||
if nachgezogen is not None:
|
||||
log_event(
|
||||
"license.resigned",
|
||||
target=str(nachgezogen["license_id"])[:12],
|
||||
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:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
|
||||
@@ -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_fingerprint ON licenses(fingerprint);
|
||||
|
||||
-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im
|
||||
-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber
|
||||
-- abgefangen: der verlierende INSERT scheitert und der 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');
|
||||
-- Hoechstens eine offene Lizenz **je Art** pro Ticket -- auf Datenbankebene,
|
||||
-- nicht nur im Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei
|
||||
-- Anfragen) sauber abgefangen: der verlierende INSERT scheitert und der
|
||||
-- Aufrufer gibt die bereits erzeugte Lizenz zurueck.
|
||||
--
|
||||
-- "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.
|
||||
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)
|
||||
|
||||
|
||||
#: 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, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
|
||||
Migration(version=2, name="revision-timestamp", sql=V2),
|
||||
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||
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 sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
import tesm_licensing as lic
|
||||
@@ -16,6 +16,28 @@ from tesm_core.db import Database
|
||||
|
||||
TICKET_TYPES = lic.LICENSE_TYPES
|
||||
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")
|
||||
|
||||
#: 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 (
|
||||
Database.value(
|
||||
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,),
|
||||
default=0,
|
||||
)
|
||||
@@ -333,8 +366,15 @@ def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
|
||||
) > 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:
|
||||
if ticket_has_open_license(conn, ticket_id):
|
||||
if ticket_has_any_open_license(conn, ticket_id):
|
||||
raise IssuanceError(
|
||||
"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."
|
||||
)
|
||||
|
||||
# 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:
|
||||
license_type = "trial"
|
||||
modules = sorted(lic.ALL_MODULES)
|
||||
@@ -507,10 +558,11 @@ def issue_from_ticket(
|
||||
# Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein
|
||||
# zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz
|
||||
# zurueck statt einen Fehler zu zeigen.
|
||||
vergleich = "=" if as_trial else "<>"
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"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,),
|
||||
)
|
||||
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)
|
||||
if row is None:
|
||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||
|
||||
document = json.loads(str(row["license_document"]))
|
||||
bundle: dict[str, Any] = {
|
||||
"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,))
|
||||
|
||||
|
||||
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(
|
||||
conn: sqlite3.Connection,
|
||||
license_id: str,
|
||||
*,
|
||||
master_private_key: str,
|
||||
changes: dict[str, Any],
|
||||
ueber_ticket: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
|
||||
|
||||
@@ -600,6 +685,18 @@ def resign_license(
|
||||
row = get_license(conn, license_id)
|
||||
if row is None:
|
||||
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"]))
|
||||
updated = licsrv.resign_license(
|
||||
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]:
|
||||
rows = list_licenses(conn)
|
||||
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(
|
||||
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
|
||||
),
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
{% else %}
|
||||
<div class="grid grid--4">
|
||||
{{ 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',
|
||||
href=url_for('licenses.overview', status='active')) }}
|
||||
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
</div>
|
||||
</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' %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
|
||||
{% else %}
|
||||
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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,8 +59,11 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="as_trial" value="1">
|
||||
<button class="btn" type="submit"
|
||||
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
|
||||
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
|
||||
{# Eine laufende **Test**lizenz sperrt diesen Knopf, eine
|
||||
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-style="primary">
|
||||
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 %}
|
||||
</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') }}
|
||||
{% elif ticket.open_license_id %}{{ pill('eingelöst', '') }}
|
||||
{% else %}{{ pill('offen', 'online') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
|
||||
Reference in new issue
Block a user