Add ticket status setter and prevent draft text loss

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:15:30 +02:00
co-authored by Claude Opus 4.8
parent 1041f283a4
commit 1107a912bc
7 changed files with 141 additions and 11 deletions
+58 -2
View File
@@ -5,7 +5,7 @@ Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit
Präfix erzeugt. Präfix erzeugt.
""" """
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from flask import (Flask, Response, abort, flash, redirect, render_template, from flask import (Flask, Response, abort, flash, redirect, render_template,
@@ -33,6 +33,36 @@ def _zammad():
return _client 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): def _sync_own_action(ticket_id):
"""Nach eigener Antwort/Neuanlage das Ticket sofort in die DB übernehmen, """Nach eigener Antwort/Neuanlage das Ticket sofort in die DB übernehmen,
damit die selbst ausgelöste Änderung nicht als Änderung gemeldet wird.""" damit die selbst ausgelöste Änderung nicht als Änderung gemeldet wird."""
@@ -178,7 +208,33 @@ def create_app() -> Flask:
).fetchall() ).fetchall()
conversation, conv_error = _load_conversation(ticket_id) conversation, conv_error = _load_conversation(ticket_id)
return render_template("ticket.html", ticket=ticket, timeline=timeline, 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/<int:ticket_id>/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/<int:ticket_id>/article/<int:article_id>/att/<int:att_id>") @app.route("/ticket/<int:ticket_id>/article/<int:article_id>/att/<int:att_id>")
def attachment(ticket_id, article_id, att_id): def attachment(ticket_id, article_id, att_id):
+22 -5
View File
@@ -126,24 +126,41 @@ class ZammadClient:
r = self.session.get(f"{self.base}/api/v1/signshow", timeout=30) r = self.session.get(f"{self.base}/api/v1/signshow", timeout=30)
return r.headers.get("CSRF-TOKEN") 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() self.ensure_auth()
headers = {} headers = {}
if not config.ZAMMAD_TOKEN: if not config.ZAMMAD_TOKEN:
csrf = self._csrf() csrf = self._csrf()
if csrf: if csrf:
headers["X-CSRF-Token"] = csrf headers["X-CSRF-Token"] = csrf
r = self.session.post(f"{self.base}{path}", json=payload, r = self.session.request(method, f"{self.base}{path}", json=payload,
headers=headers, timeout=60) headers=headers, timeout=60)
if r.status_code in (401, 403) and _retry and not config.ZAMMAD_TOKEN: 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) log.warning("Auth/CSRF abgelaufen (HTTP %s), erneuter Login.", r.status_code)
self._authed = False self._authed = False
self._login_session() 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): 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() 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, def create_article(self, ticket_id: int, body: str,
attachments: list[dict] | None = None) -> dict: attachments: list[dict] | None = None) -> dict:
"""Antwort/Nachricht an ein bestehendes Ticket anhängen (an EPI sichtbar).""" """Antwort/Nachricht an ein bestehendes Ticket anhängen (an EPI sichtbar)."""
+43
View File
@@ -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);
})();
+4
View File
@@ -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; } .state-new { background: #fff3d6; color: #8a6300; }
[class^="state-"].state-pending\ reminder, .state-pending { background: #e6f0fd; color: #1c54a8; } [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 { 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 div { display: flex; justify-content: space-between; border-bottom: 1px dotted var(--line); padding: .25rem 0; }
.meta dt { color: var(--muted); margin: 0; } .meta dt { color: var(--muted); margin: 0; }
+1 -1
View File
@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}EPI-Support{% endblock %}</title> <title>{% block title %}EPI-Support{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta http-equiv="refresh" content="120">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -32,6 +31,7 @@
<footer> <footer>
EPI-Support-Monitor · liest <code>{{ zammad_url }}</code> · Seite aktualisiert sich automatisch EPI-Support-Monitor · liest <code>{{ zammad_url }}</code> · Seite aktualisiert sich automatisch
</footer> </footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+2 -2
View File
@@ -9,7 +9,7 @@
<form method="post" action="{{ url_for('new_ticket_preview') }}" <form method="post" action="{{ url_for('new_ticket_preview') }}"
class="form attach-form" enctype="multipart/form-data"> class="form attach-form" enctype="multipart/form-data">
<label>Titel / Betreff <label>Titel / Betreff
<input type="text" name="title" required maxlength="200" <input type="text" name="title" required maxlength="200" data-draft
placeholder="Kurze Zusammenfassung des Anliegens"> placeholder="Kurze Zusammenfassung des Anliegens">
</label> </label>
<label>Gruppe <label>Gruppe
@@ -20,7 +20,7 @@
</select> </select>
</label> </label>
<label>Nachricht <label>Nachricht
<textarea name="body" rows="10" required <textarea name="body" rows="10" required data-draft
placeholder="Beschreibe dein Anliegen… (Screenshot mit Strg+V einfügen)"></textarea> placeholder="Beschreibe dein Anliegen… (Screenshot mit Strg+V einfügen)"></textarea>
</label> </label>
{% include "_attach.html" %} {% include "_attach.html" %}
+11 -1
View File
@@ -20,6 +20,16 @@
<div><dt>zuletzt geändert</dt><dd>{{ ticket.zammad_updated_at|dt }}</dd></div> <div><dt>zuletzt geändert</dt><dd>{{ ticket.zammad_updated_at|dt }}</dd></div>
<div><dt>geschlossen</dt><dd>{{ ticket.last_close_at|dt }}</dd></div> <div><dt>geschlossen</dt><dd>{{ ticket.last_close_at|dt }}</dd></div>
</dl> </dl>
<form method="post" action="{{ url_for('set_state', ticket_id=ticket.id) }}" class="state-form">
<label for="state_id">Status ändern:</label>
<select id="state_id" name="state_id">
{% for s in settable_states %}
<option value="{{ s.id }}" {{ 'selected' if s.id == ticket.state_id }}>{{ s.label }}</option>
{% endfor %}
</select>
<button class="btn-sm" type="submit">setzen</button>
<span class="hint-inline">wirkt live in Zammad</span>
</form>
</section> </section>
<section class="panel"> <section class="panel">
@@ -75,7 +85,7 @@
{% if ticket.state != 'closed' %} {% if ticket.state != 'closed' %}
<form method="post" action="{{ url_for('reply_preview', ticket_id=ticket.id) }}" <form method="post" action="{{ url_for('reply_preview', ticket_id=ticket.id) }}"
class="reply-form attach-form" enctype="multipart/form-data"> class="reply-form attach-form" enctype="multipart/form-data">
<textarea name="body" rows="4" required <textarea name="body" rows="4" required data-draft
placeholder="Antwort an den EPI-Support verfassen… (Screenshot mit Strg+V einfügen)"></textarea> placeholder="Antwort an den EPI-Support verfassen… (Screenshot mit Strg+V einfügen)"></textarea>
{% include "_attach.html" %} {% include "_attach.html" %}
<div class="form-actions"> <div class="form-actions">