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:
+58
-2
@@ -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):
|
||||
|
||||
+22
-5
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user