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]
|
[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))
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user