diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index d98ef7e..bcf7f8e 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.8.12" +version = "2.8.13" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/blueprints/licenses.py b/apps/tesm-license/src/tesm_license/blueprints/licenses.py index d81fad3..478d88f 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/licenses.py +++ b/apps/tesm-license/src/tesm_license/blueprints/licenses.py @@ -78,6 +78,9 @@ def detail(license_id: str): "license/license_detail.html", license=row, state=issuance.display_state(row), + # Bis wohin traegt das Ticket? Die Seite zeigt eine Abweichung an, + # statt sie erst in einer Fehlermeldung auftauchen zu lassen. + ticket_grenze=issuance.ticket_grenze(conn, license_id), document=document, document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True), deliveries=Database.all( @@ -181,6 +184,48 @@ def download(license_id: str): ) +@bp.post("//align-ticket") +@permission_required("license_renew.execute") +@reauth_required +def align_ticket(license_id: str): + """Bringt die Laufzeit der Lizenz mit ihrem Ticket zur Deckung. + + In beide Richtungen. Verkuerzt sie, ist das eine Ruecknahme von Laufzeit -- + deshalb nur auf ausdruecklichen Zugriff, mit Rueckfrage in der Oberflaeche + und mit Zeile im Aenderungsprotokoll. + """ + conn = db() + if issuance.get_license(conn, license_id) is None: + abort(404) + key = signing_key() + try: + with transaction(): + neu = issuance.lizenz_auf_ticket_setzen( + conn, license_id, master_private_key=key.private_key + ) + except (issuance.IssuanceError, ValueError) as exc: + flash(str(exc), "danger") + return redirect(url_for("licenses.detail", license_id=license_id)) + + if neu is None: + flash("Die Laufzeit entspricht bereits dem Ticket.", "info") + return redirect(url_for("licenses.detail", license_id=license_id)) + + log_event( + "license.resigned", + target=license_id[:12], + detail=f"Revision {neu['seq']}: Laufzeit auf die Ticketlaufzeit gesetzt", + severity="warning", + ) + reporting.report_soon(core()) + flash( + f"Neue Revision {neu['seq']}: Die Laufzeit entspricht jetzt dem Ticket. Der Client " + "erhält sie beim nächsten Heartbeat.", + "success", + ) + return redirect(url_for("licenses.detail", license_id=license_id)) + + @bp.post("//resign") @permission_required("license_renew.execute") @reauth_required diff --git a/apps/tesm-license/src/tesm_license/blueprints/tickets.py b/apps/tesm-license/src/tesm_license/blueprints/tickets.py index 0236ebb..14ce8ff 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/tickets.py +++ b/apps/tesm-license/src/tesm_license/blueprints/tickets.py @@ -217,6 +217,56 @@ def update(ticket_id: str): return redirect(url_for("tickets.detail", ticket_id=ticket_id)) +@bp.post("//extend") +@permission_required("tickets.write") +def extend(ticket_id: str): + """Schlaegt Tage auf die Laufzeit des Tickets -- und zieht die Lizenz nach.""" + conn = db() + if issuance.get_ticket(conn, ticket_id) is None: + abort(404) + try: + tage = int(request.form.get("days") or 0) + except ValueError: + flash("Die Verlängerung muss eine Zahl sein.", "danger") + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + + try: + with transaction(): + neu = issuance.ticket_verlaengern(conn, ticket_id, tage) + nachgezogen = issuance.lizenz_dem_ticket_nachziehen( + conn, ticket_id, master_private_key=signing_key().private_key + ) + except (issuance.IssuanceError, ValueError) as exc: + flash(str(exc), "danger") + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + + log_event( + "ticket.updated", + target=ticket_id[:12], + detail=f"Laufzeit +{tage} Tage (jetzt {neu})", + severity="notice", + scope="ticket", + ) + 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", + ) + reporting.report_soon(core()) + flash( + f"Ticket auf {neu} Tage verlängert." + + ( + f" Die laufende Lizenz wurde mitverlängert (Revision {nachgezogen['seq']})." + if nachgezogen is not None + else "" + ), + "success", + ) + return redirect(url_for("tickets.detail", ticket_id=ticket_id)) + + @bp.post("//lock") @permission_required("tickets.write") def toggle_block(ticket_id: str): diff --git a/apps/tesm-license/src/tesm_license/services/issuance.py b/apps/tesm-license/src/tesm_license/services/issuance.py index 0544b6d..9dc834d 100644 --- a/apps/tesm-license/src/tesm_license/services/issuance.py +++ b/apps/tesm-license/src/tesm_license/services/issuance.py @@ -814,6 +814,28 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s } +def ticket_verlaengern(conn: sqlite3.Connection, ticket_id: str, tage: int) -> int: + """Schlaegt Tage auf die Laufzeit des Tickets auf. Rueckgabe: die neue Zahl. + + Dasselbe Bedienmuster wie bei der Lizenz ("+ X Tage"), und aus demselben + Grund: wer verlaengert, rechnet in Tagen ab heute und nicht in einer neuen + Gesamtzahl, die er sich erst aus dem alten Wert zusammensuchen muss. + """ + ticket = get_ticket(conn, ticket_id) + if ticket is None: + raise IssuanceError("Dieses Ticket existiert nicht.") + if bool(ticket["lifetime"]): + raise IssuanceError("Dieses Ticket ist unbefristet -- da gibt es nichts zu verlängern.") + if int(tage) <= 0: + raise IssuanceError("Die Verlängerung muss eine Zahl größer als null sein.") + neu = int(ticket["valid_days"]) + int(tage) + conn.execute( + "UPDATE license_tickets SET valid_days=?, updated_at=? WHERE id=?", + (neu, _now(), ticket_id), + ) + return neu + + def lizenz_dem_ticket_nachziehen( conn: sqlite3.Connection, ticket_id: str, *, master_private_key: str ) -> dict[str, Any] | None: @@ -864,6 +886,45 @@ def lizenz_dem_ticket_nachziehen( ) +def lizenz_auf_ticket_setzen( + conn: sqlite3.Connection, license_id: str, *, master_private_key: str +) -> dict[str, Any] | None: + """Setzt die Laufzeit einer Lizenz auf das, was ihr Ticket hergibt. + + In beide Richtungen -- das ist der Unterschied zum Nachziehen bei einer + Ticketverlaengerung, das nur verlaengert. Hier greift jemand ausdruecklich + zu, um Anspruch und Urkunde wieder zur Deckung zu bringen: eine Lizenz, die + laenger laeuft als ihr Ticket, ist eine Zusage ohne Grundlage. + + **Das verkuerzt eine bereits ausgestellte Zusage.** Der Client uebernimmt + die neue Revision beim naechsten Heartbeat, und danach laeuft die + Installation frueher ab. Deshalb passiert es nur auf ausdruecklichen + Zugriff und nie nebenbei. + + Rueckgabe: die neue Revision, oder ``None``, wenn nichts zu tun war. + """ + row = get_license(conn, license_id) + if row is None: + raise IssuanceError("Diese Lizenz existiert nicht.") + if bool(row["lifetime"]): + raise IssuanceError("Diese Lizenz ist unbefristet.") + grenze = ticket_grenze(conn, license_id) + if grenze is None: + raise IssuanceError( + "Zu dieser Lizenz gibt es keine begrenzte Ticketlaufzeit, an der sie " + "auszurichten wäre." + ) + if 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": []} diff --git a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html index 312a96b..730d867 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html @@ -130,13 +130,43 @@ {% endif %} {{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number', placeholder='0', - hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }} + hint_text=('Höchstens bis zum Ende der Ticketlaufzeit (' ~ (ticket_grenze | dt) ~ ') -- weiter geht es nur über das Ticket.') if ticket_grenze else 'Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }} + + {# Anspruch und Urkunde wieder zur Deckung bringen. In beide Richtungen -- + und wenn es verkürzt, ist das eine Rücknahme von Laufzeit: deshalb mit + Rückfrage und eigener Zeile im Änderungsprotokoll. #} + {% if ticket_grenze and ticket_grenze != license.expires_at %} +
+
+
+
{{ icon('shield', size=17) }} Laufzeit weicht vom Ticket ab
+
+ Diese Lizenz läuft bis {{ license.expires_at | dt }}, das Ticket + gewährt bis {{ ticket_grenze | dt }}. +
+
+
+
+ + +
+
+ {% endif %} {% endif %}
diff --git a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html index b4cd5cf..7526c91 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html @@ -189,6 +189,7 @@ {% endif %} {{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number', + hint_text='Gesamtlaufzeit ab Ausstellung. Aufschlagen geht unten.', disabled=not current_user.has('tickets.write')) }} {{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime, disabled=not current_user.has('tickets.write')) }} @@ -208,6 +209,34 @@
+ {# Dasselbe Bedienmuster wie bei der Lizenz: wer verlängert, rechnet in + Tagen ab jetzt und nicht in einer neuen Gesamtzahl, die er sich erst aus + dem alten Wert zusammensuchen muss. Und weil das Ticket der Anspruch + ist, zieht die laufende Lizenz gleich mit. #} + {% if current_user.has('tickets.write') and not ticket.lifetime %} +
+
+
+
{{ icon('clock', size=17) }} Laufzeit verlängern
+
+ Schlägt Tage auf die {{ ticket.valid_days }} Tage dieses Tickets auf. + Eine laufende Lizenz wird dabei mitverlängert – sie erhält eine neue + Revision und übernimmt sie beim nächsten Heartbeat. +
+
+
+
+ +
+ {{ field('days', 'Verlängern um (Tage)', type='number', placeholder='30') }} +
+ +
+
+ {% endif %} + {% if current_user.has('tickets.write') or current_user.has('tickets.delete') %}