Files
tesm-license/srv/tesm-license/templates/tickets.html
T
alientimandClaude Sonnet 5 cee5311b1d Ticketverwaltung, Kundenstammdaten, Kundenportal, Lifetime-Lizenzen, Dashboard-Kacheln (v1.2.0)
Reaktion auf mehrere Bug-Reports und Feature-Wuensche zum bestehenden
Ticket-System (v1.1.0): 'Logs gehen nicht', 'Ticketverwaltung fehlt',
'Lizenzen koennen nicht vom Dashboard geloescht werden', plus eine Reihe
konkreter neuer Anforderungen.

## Bugfixes

- /logs: behob einen Absturz (jinja2.exceptions.UndefinedError:
  'global_check_interval' is undefined) UND die eigentliche Ursache
  dahinter -- logs.html war unveraendert aus dem TESM-Fork uebernommen
  (PoE-Geraete-Polling-Text/-Farblogik, ein Template-Feld, das app.py nie
  befuellt hat) und ausserdem schrieb auf diesem Server ueberhaupt nichts
  in live.log (kein poe.sh-Aequivalent vorhanden). Live-Log zeigt jetzt
  echte Ticket-/Lizenzaktionen, mit passendem Hinweistext und Faerbung.
- Lizenz-Loeschung war bereits vorhanden, aber nur ueber Ticket- ->
  Lizenz-Detailseite erreichbar -- jetzt zusaetzlich inline in der
  Lizenzhistorie jedes Tickets.

## Ticketverwaltung (neu: eigene Seite /tickets)

- Volle CRUD: Liste mit Suche, Bearbeiten (Typ/Module/Laufzeit/Lifetime,
  wirkt nur auf die naechste Ausstellung), Loeschen (nur wenn keine
  offene Lizenz mehr besteht, dann inkl. Historie).
- Dashboard (/) zeigt seitdem NICHT mehr die Ticket-Tabelle, sondern eine
  rein informative Kachel-Uebersicht der aktuell AKTIVEN Lizenzen, exakt
  im Kachel-Stil von TESMs eigenem Geraete-Dashboard: Ampelfarben (gruen
  >90 Tage, orange 30-90, rot <30/abgelaufen), gruppiert, Firma + Hostname
  der Kundeninstanz. Hostname kommt neu vom TESM-Client per Activate/
  Heartbeat (srv/tesm/licensing.py, abwaerts-kompatibel: alte Clients ohne
  das Feld ueberschreiben nie einen bereits bekannten Hostnamen).

## Kundenstammdaten

- Neue Felder: Strasse/Hausnummer/PLZ/Ort/Ansprechpartner. E-Mail ist beim
  Anlegen jetzt Pflicht; beim Bearbeiten bewusst nur validiert, wenn
  ausgefuellt (sonst waeren Bestandskunden aus der Zeit vor dieser
  Umstellung fuer JEDE Aenderung blockiert gewesen).

## Kundenportal (neu, per Magic-Link, kein Login)

- /portal/<token>: zeigt einem Kunden ALLE seine Tickets/Lizenzen auf
  einen Blick (ergaenzt den bestehenden Pro-Ticket-Link
  /self-service/<ticket_id>) und erlaubt ihm, seine eigenen Kontakt-/
  Anschriftdaten selbst zu pflegen -- Firmenname und kommerzielle
  Ticket-Eckdaten bleiben bewusst admin-verwaltet. token = eigene Spalte
  license_customers.portal_token, rueckwirkend fuer Bestandskunden erzeugt.

## Lifetime-Lizenzen

- Neues Ticket-Flag 'Lifetime': ignoriert die Laufzeit fuer jede REGULAERE
  Ausstellung (Sentinel-Ablaufdatum 2099-12-31, licensing.
  LIFETIME_EXPIRES_AT) -- eine zusaetzlich erzeugte Trial-Lizenz aus
  demselben Ticket bleibt immer die normale 30-Tage-Variante. Trial ALS
  TICKET-GRUNDTYP + Lifetime wird serverseitig verhindert (haette eine
  dauerhafte, nie ablaufende Lizenz ganz ohne Module ergeben).

## Absicherung nach Review

Ein Adversarial-Review-Durchlauf ueber den kompletten Diff deckte vor dem
Deploy zusaetzlich auf und wurde behoben:
- TOCTOU-Race: zwei nahezu gleichzeitige 'Lizenz erstellen'-Anfragen fuer
  dasselbe Ticket (z.B. Doppelklick im Self-Service) konnten beide
  durchkommen. Jetzt durch einen partiellen Unique-Index
  (idx_one_open_license_per_ticket) auf DB-Ebene ausgeschlossen; die
  zweite Anfrage bekommt sauber die bereits erstellte Lizenz zurueck statt
  eine zweite zu erzeugen.
- Analoger Unique-Index auf license_customers.portal_token als Backstop
  fuer den neuen Magic-Link.
- tickets.html nutzte noch die alte, feste '<=30 Tage'-Schwelle statt der
  gemeinsamen Ampelfarbe -- Lizenzen im 30-90-Tage-Bereich blieben dort
  unmarkiert, obwohl das Dashboard sie schon als orange zeigte.

SCHEMA_VERSION 2 -> 3 (neue Spalten/Tabellen, ueber install.sh mit
Backup+Health-Check abgesichert).

Verifiziert: vollstaendige Migration gegen eine echte Kopie der
Live-Datenbank, anschliessend 22 Funktionstests direkt gegen die echten
Routen (Validierung, Lifetime-Ausstellung, Sperr-/Kaskadenregeln bei
Loeschungen, Kundenportal inkl. ungueltiger Eingaben) sowie ein Playwright-
Seitenladetest ohne Konsolenfehler -- beides nach den Review-Fixes erneut
komplett durchlaufen, inkl. direktem Nachweis, dass der neue Unique-Index
eine doppelte offene Lizenz pro Ticket tatsaechlich auf DB-Ebene ablehnt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:51:20 +02:00

191 lines
9.2 KiB
HTML

{% extends "base.html" %}
{% set active_page = "tickets" %}
{% block page_title %}Tickets{% endblock %}
{% block page_sub %}<div class="topbar-sub">Ticketverwaltung -- Anlegen, Bearbeiten, Löschen</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Tickets</h2>
<div class="hint">Jedes Ticket ist die dauerhafte Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten -- die eigentliche Lizenzdatei darunter ist austauschbar.</div>
</div>
{% if can_create %}
<a href="{{ url_for('ticket_new') }}" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neues Ticket
</a>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="ticketSearch" placeholder="Tickets durchsuchen…" oninput="filterTicketsTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="ticketsTable" data-sortable>
<thead><tr>
<th data-sort-key="customer">Kunde</th>
<th data-sort-key="type">Typ</th>
<th>Module</th>
<th data-sort-key="duration">Laufzeit</th>
<th data-sort-key="ticketstatus">Ticket</th>
<th data-sort-key="status">Lizenz-Status</th>
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
{% for t in tickets %}
<tbody data-sort-customer="{{ t.customer_name|lower }}" data-sort-type="{{ t.type }}"
data-sort-duration="{{ 999999 if t.lifetime else t.valid_days }}"
data-sort-ticketstatus="{{ t.status }}"
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 '' }}">
<tr>
<td class="cell-name">{{ t.customer_name }}</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">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>
<td><span class="pill {{ 'online' if t.status == 'open' else 'offline' }}">{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}</span></td>
{% if t.license %}
<td>
<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>
{% if t.license.status == 'active' and not t.license.is_lifetime and t.license.days_left is not none %}
{% if t.license.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
{% elif t.license.color in ('red', 'orange') %}
<span style="color:{{ 'var(--danger)' if t.license.color == 'red' else 'var(--warning)' }};">({{ t.license.days_left }} Tage)</span>
{% endif %}
{% endif %}
</td>
<td class="text-dim">{{ t.license.last_heartbeat_at or '—' }}</td>
{% else %}
<td class="text-faint">Noch keine Lizenz</td>
<td class="text-dim"></td>
{% endif %}
<td>
<div class="row-actions">
<a class="icon-btn" title="Ticket-Details" href="{{ url_for('ticket_detail', ticket_id=t.id) }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</a>
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editTicketModal{{ loop.index }}">
<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>
</button>
{% if t.can_delete %}
<form method="post" action="{{ url_for('ticket_delete', ticket_id=t.id) }}"
data-confirm="Ticket für „{{ t.customer_name }}“ wirklich löschen? Die Lizenzhistorie dieses Tickets geht dabei verloren.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
{% endif %}
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="8">Noch keine Tickets angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
{% if can_edit %}
{% for t in tickets %}
<div class="modal-overlay" id="editTicketModal{{ loop.index }}">
<div class="modal" style="max-width:520px;">
<form method="post" action="{{ url_for('ticket_edit', ticket_id=t.id) }}">
<div class="modal-header">
<h3>Ticket bearbeiten -- {{ t.customer_name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="hint" style="margin-bottom:14px;">
Wirkt sich nur auf die NÄCHSTE aus diesem Ticket erstellte Lizenz aus -- bereits ausgestellte Lizenzen bleiben unverändert.
</div>
<div class="field">
<label>Lizenztyp</label>
<select name="license_type" id="editLicenseType{{ loop.index }}" onchange="toggleEditModuleFields({{ loop.index }})">
{% for lt in license_types %}
<option value="{{ lt }}" {% if lt == t.type %}selected{% endif %}>{{ type_labels.get(lt, lt) }}</option>
{% endfor %}
</select>
</div>
<div id="editModuleFields{{ loop.index }}" class="field {% if t.type != 'custom' %}hidden{% endif %}">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}" {% if m in t.modules_list %}checked{% endif %}>
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label class="check-row">
<input type="checkbox" name="lifetime" value="1" id="editLifetime{{ loop.index }}"
{% if t.lifetime %}checked{% endif %} {% if t.type == 'trial' %}disabled{% endif %}
onchange="toggleEditLifetime({{ loop.index }})">
Lifetime (unbegrenzte Laufzeit)
</label>
</div>
<div class="field" id="editValidDaysField{{ loop.index }}" {% if t.lifetime %}style="display:none;"{% endif %}>
<label>Gültigkeitsdauer (Tage)</label>
<input type="number" name="valid_days" id="editValidDays{{ loop.index }}" value="{{ t.valid_days }}" min="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function filterTicketsTable() {
const el = document.getElementById("ticketSearch");
if (!el) return;
const q = el.value.trim().toLowerCase();
document.querySelectorAll("#ticketsTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function toggleEditModuleFields(i) {
const type = document.getElementById("editLicenseType" + i).value;
document.getElementById("editModuleFields" + i).classList.toggle("hidden", type !== "custom");
// Trial + Lifetime ergibt keinen Sinn (dauerhaft nie ablaufende Lizenz
// ohne jedes Modul) -- serverseitig ohnehin unterbunden, hier nur
// als UI-Signal ausgegraut.
const lifetimeBox = document.getElementById("editLifetime" + i);
lifetimeBox.disabled = (type === "trial");
if (type === "trial" && lifetimeBox.checked) {
lifetimeBox.checked = false;
toggleEditLifetime(i);
}
}
function toggleEditLifetime(i) {
const checked = document.getElementById("editLifetime" + i).checked;
document.getElementById("editValidDaysField" + i).style.display = checked ? "none" : "";
}
</script>
{% endblock %}