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>
191 lines
9.2 KiB
HTML
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>×</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 %}
|