From 1107a912bcf6677acc1786342b73f2bcb83784c1 Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Wed, 1 Jul 2026 11:15:30 +0200 Subject: [PATCH] Add ticket status setter and prevent draft text loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set ticket state (Offen / In Bearbeitung / Warten auf Schließen / Geschlossen) from the ticket detail page via a live PUT to Zammad; pending-close gets a default pending time and the change is synced back so it isn't flagged as an external change - Replace the disruptive meta-refresh with a JS auto-refresh that pauses while a text field has unsent content or focus - Persist reply/new-ticket drafts to localStorage and restore them after a reload or accidental navigation Co-Authored-By: Claude Opus 4.8 --- app/web.py | 60 +++++++++++++++++++++++++++++++++++++-- app/zammad.py | 27 ++++++++++++++---- static/app.js | 43 ++++++++++++++++++++++++++++ static/style.css | 4 +++ templates/base.html | 2 +- templates/new_ticket.html | 4 +-- templates/ticket.html | 12 +++++++- 7 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 static/app.js diff --git a/app/web.py b/app/web.py index f6a4c0b..438727a 100644 --- a/app/web.py +++ b/app/web.py @@ -5,7 +5,7 @@ Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit Präfix erzeugt. """ import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from flask import (Flask, Response, abort, flash, redirect, render_template, @@ -33,6 +33,36 @@ def _zammad(): return _client +# Über die Oberfläche setzbare Status (Name -> deutsche Beschriftung) +STATE_LABELS = { + "open": "Offen", + "in Bearbeitung": "In Bearbeitung", + "pending close": "Warten auf Schließen", + "closed": "Geschlossen", +} +_states_cache = None + + +def _settable_states(): + """Aktive, für den Nutzer sinnvolle Status – einmal von Zammad geholt.""" + global _states_cache + if _states_cache is None: + by_name = {} + try: + for s in _zammad().ticket_states(): + if s.get("active"): + by_name[s.get("name")] = s + except Exception as e: # noqa: BLE001 + log.warning("ticket_states nicht abrufbar: %s", e) + out = [{"id": by_name[n]["id"], "name": n, "label": lbl} + for n, lbl in STATE_LABELS.items() if n in by_name] + if not out: # Rückfall, falls Abruf scheitert + fb = {"open": 2, "in Bearbeitung": 8, "pending close": 7, "closed": 4} + out = [{"id": i, "name": n, "label": STATE_LABELS[n]} for n, i in fb.items()] + _states_cache = out + return _states_cache + + def _sync_own_action(ticket_id): """Nach eigener Antwort/Neuanlage das Ticket sofort in die DB übernehmen, damit die selbst ausgelöste Änderung nicht als Änderung gemeldet wird.""" @@ -178,7 +208,33 @@ def create_app() -> Flask: ).fetchall() conversation, conv_error = _load_conversation(ticket_id) return render_template("ticket.html", ticket=ticket, timeline=timeline, - conversation=conversation, conv_error=conv_error) + conversation=conversation, conv_error=conv_error, + settable_states=_settable_states()) + + @app.route("/ticket//state", methods=["POST"]) + def set_state(ticket_id): + try: + state_id = int(request.form.get("state_id", "")) + except ValueError: + flash("Ungültiger Status.") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + states = _settable_states() + target = next((s for s in states if s["id"] == state_id), None) + if not target: + flash("Dieser Status ist nicht zulässig.") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + # "Warten auf Schließen" ist ein Pending-Status und braucht einen Zeitpunkt + pending = None + if target["name"] == "pending close": + pending = (datetime.now(timezone.utc) + timedelta(days=7) + ).replace(microsecond=0).isoformat() + try: + _zammad().set_state(ticket_id, state_id, pending) + _sync_own_action(ticket_id) + flash(f"✓ Status auf „{target['label']}“ gesetzt.") + except ZammadError as e: + flash("✗ Status konnte nicht gesetzt werden: " + str(e)) + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) @app.route("/ticket//article//att/") def attachment(ticket_id, article_id, att_id): diff --git a/app/zammad.py b/app/zammad.py index 255c642..392ecda 100644 --- a/app/zammad.py +++ b/app/zammad.py @@ -126,24 +126,41 @@ class ZammadClient: r = self.session.get(f"{self.base}/api/v1/signshow", timeout=30) return r.headers.get("CSRF-TOKEN") - def _post(self, path: str, payload: dict, _retry: bool = True): + def _write(self, method: str, path: str, payload: dict, _retry: bool = True): self.ensure_auth() headers = {} if not config.ZAMMAD_TOKEN: csrf = self._csrf() if csrf: headers["X-CSRF-Token"] = csrf - r = self.session.post(f"{self.base}{path}", json=payload, - headers=headers, timeout=60) + r = self.session.request(method, f"{self.base}{path}", json=payload, + headers=headers, timeout=60) if r.status_code in (401, 403) and _retry and not config.ZAMMAD_TOKEN: log.warning("Auth/CSRF abgelaufen (HTTP %s), erneuter Login.", r.status_code) self._authed = False self._login_session() - return self._post(path, payload, _retry=False) + return self._write(method, path, payload, _retry=False) if r.status_code not in (200, 201): - raise ZammadError(f"POST {path} -> HTTP {r.status_code}: {r.text[:300]}") + raise ZammadError(f"{method} {path} -> HTTP {r.status_code}: {r.text[:300]}") return r.json() + def _post(self, path: str, payload: dict, _retry: bool = True): + return self._write("POST", path, payload, _retry) + + def _put(self, path: str, payload: dict, _retry: bool = True): + return self._write("PUT", path, payload, _retry) + + def ticket_states(self) -> list[dict]: + return self._get("/api/v1/ticket_states") + + def set_state(self, ticket_id: int, state_id: int, + pending_time: str | None = None) -> dict: + """Status eines Tickets setzen (an EPI wirksam).""" + payload = {"state_id": state_id} + if pending_time: + payload["pending_time"] = pending_time + return self._put(f"/api/v1/tickets/{ticket_id}", payload) + def create_article(self, ticket_id: int, body: str, attachments: list[dict] | None = None) -> dict: """Antwort/Nachricht an ein bestehendes Ticket anhängen (an EPI sichtbar).""" diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..105add5 --- /dev/null +++ b/static/app.js @@ -0,0 +1,43 @@ +// Allgemeines Verhalten: Entwurf-Speicherung + ungefährlicher Auto-Refresh. +(function () { + // --- 1) Entwürfe sichern (überlebt Reload / versehentliches Wegnavigieren) --- + function draftKey(el) { + return "episupport-draft:" + location.pathname + ":" + (el.name || el.id || "x"); + } + var drafts = document.querySelectorAll("[data-draft]"); + drafts.forEach(function (el) { + try { + var saved = localStorage.getItem(draftKey(el)); + if (saved && !el.value) el.value = saved; + } catch (e) {} + el.addEventListener("input", function () { + try { + if (el.value) localStorage.setItem(draftKey(el), el.value); + else localStorage.removeItem(draftKey(el)); + } catch (e) {} + }); + }); + // Beim Absenden die zugehörigen Entwürfe löschen + document.querySelectorAll("form").forEach(function (form) { + form.addEventListener("submit", function () { + form.querySelectorAll("[data-draft]").forEach(function (el) { + try { localStorage.removeItem(draftKey(el)); } catch (e) {} + }); + }); + }); + + // --- 2) Auto-Refresh, aber NIE während des Tippens/ungesendeten Texts --- + var INTERVAL = 120000; // 2 Minuten + function hasUnsavedInput() { + var a = document.activeElement; + if (a && /^(TEXTAREA|INPUT|SELECT)$/.test(a.tagName)) return true; + var fields = document.querySelectorAll("textarea, input[type=text], input:not([type])"); + for (var i = 0; i < fields.length; i++) { + if ((fields[i].value || "").trim() !== "") return true; + } + return false; + } + setInterval(function () { + if (!hasUnsavedInput()) location.reload(); + }, INTERVAL); +})(); diff --git a/static/style.css b/static/style.css index d9d335d..2369bc4 100644 --- a/static/style.css +++ b/static/style.css @@ -136,6 +136,10 @@ table.tickets tr.row-unseen td:first-child { box-shadow: inset 3px 0 0 var(--acc .state-new { background: #fff3d6; color: #8a6300; } [class^="state-"].state-pending\ reminder, .state-pending { background: #e6f0fd; color: #1c54a8; } +.state-form { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; padding: 0 1.1rem 1rem; } +.state-form label { font-size: .85rem; font-weight: 600; color: #44525e; } +.state-form select { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: 7px; background: #fff; } + .meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: .4rem 2rem; padding: 1rem 1.1rem; margin: 0; } .meta div { display: flex; justify-content: space-between; border-bottom: 1px dotted var(--line); padding: .25rem 0; } .meta dt { color: var(--muted); margin: 0; } diff --git a/templates/base.html b/templates/base.html index 6582632..040df77 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,7 +5,6 @@ {% block title %}EPI-Support{% endblock %} -
@@ -32,6 +31,7 @@
EPI-Support-Monitor · liest {{ zammad_url }} · Seite aktualisiert sich automatisch
+ {% block scripts %}{% endblock %} diff --git a/templates/new_ticket.html b/templates/new_ticket.html index 038a996..9feb82e 100644 --- a/templates/new_ticket.html +++ b/templates/new_ticket.html @@ -9,7 +9,7 @@
{% include "_attach.html" %} diff --git a/templates/ticket.html b/templates/ticket.html index 7cc0584..da0f7c8 100644 --- a/templates/ticket.html +++ b/templates/ticket.html @@ -20,6 +20,16 @@
zuletzt geändert
{{ ticket.zammad_updated_at|dt }}
geschlossen
{{ ticket.last_close_at|dt }}
+ + + + + wirkt live in Zammad +
@@ -75,7 +85,7 @@ {% if ticket.state != 'closed' %}
- {% include "_attach.html" %}