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.
"""
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/<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>")
def attachment(ticket_id, article_id, att_id):