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)."""
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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; }
|
||||
|
||||
+1
-1
@@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}EPI-Support{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<meta http-equiv="refresh" content="120">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -32,6 +31,7 @@
|
||||
<footer>
|
||||
EPI-Support-Monitor · liest <code>{{ zammad_url }}</code> · Seite aktualisiert sich automatisch
|
||||
</footer>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<form method="post" action="{{ url_for('new_ticket_preview') }}"
|
||||
class="form attach-form" enctype="multipart/form-data">
|
||||
<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">
|
||||
</label>
|
||||
<label>Gruppe
|
||||
@@ -20,7 +20,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
</label>
|
||||
{% include "_attach.html" %}
|
||||
|
||||
+11
-1
@@ -20,6 +20,16 @@
|
||||
<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>
|
||||
</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 class="panel">
|
||||
@@ -75,7 +85,7 @@
|
||||
{% if ticket.state != 'closed' %}
|
||||
<form method="post" action="{{ url_for('reply_preview', ticket_id=ticket.id) }}"
|
||||
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>
|
||||
{% include "_attach.html" %}
|
||||
<div class="form-actions">
|
||||
|
||||
Reference in New Issue
Block a user