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.
|
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):
|
||||||
|
|||||||
+21
-4
@@ -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)."""
|
||||||
|
|||||||
@@ -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; }
|
.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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user