- Let the user mark closed tickets as really done and hide them, so tickets closed prematurely by an agent stay visible until confirmed - User-side "archived" flag stored in the DB (independent of Zammad), added via a schema migration on existing databases - Hide archived tickets from the overview by default with an "erledigte einblenden" toggle; archive/unarchive from the ticket detail page and via a per-row quick action - Poller automatically un-hides an archived ticket when new activity is detected, so nothing is missed Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
468 lines
19 KiB
Python
468 lines
19 KiB
Python
"""Flask-Dashboard für den EPI-Support-Monitor.
|
||
|
||
Läuft hinter Apache unter dem Pfad EPISUPPORT_URL_PREFIX (z. B. /episupport).
|
||
Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit
|
||
Präfix erzeugt.
|
||
"""
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
||
from flask import (Flask, Response, abort, flash, redirect, render_template,
|
||
request, url_for)
|
||
|
||
from . import analytics, charts, config, conversation, db, export, poller, uploads
|
||
from .zammad import ZammadClient, ZammadError
|
||
|
||
log = logging.getLogger("episupport.web")
|
||
|
||
try:
|
||
_DISPLAY_TZ = ZoneInfo(config.DISPLAY_TZ)
|
||
except (ZoneInfoNotFoundError, ValueError):
|
||
log.warning("Zeitzone %s nicht verfügbar – zeige UTC.", config.DISPLAY_TZ)
|
||
_DISPLAY_TZ = timezone.utc
|
||
|
||
# Lazy initialisierter Zammad-Client für Live-Abrufe (Konversation).
|
||
_client = None
|
||
|
||
|
||
def _zammad():
|
||
global _client
|
||
if _client is None:
|
||
_client = ZammadClient()
|
||
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."""
|
||
try:
|
||
poller.sync_ticket(_zammad(), ticket_id)
|
||
except Exception as e: # noqa: BLE001 - darf den Sendevorgang nie stören
|
||
log.warning("sync_ticket nach eigener Aktion fehlgeschlagen (%s): %s",
|
||
ticket_id, e)
|
||
|
||
|
||
def _load_conversation(ticket_id):
|
||
"""Konversation aus der DB laden. Ist noch nichts gespeichert, einmalig
|
||
live nachladen (kurzer Timeout, damit die Seite nicht hängt) und cachen.
|
||
Gibt (articles, error) zurück."""
|
||
arts = conversation.load_articles(ticket_id)
|
||
if arts:
|
||
return arts, None
|
||
try:
|
||
conversation.store_articles(_zammad(), ticket_id, timeout=20)
|
||
except ZammadError as e:
|
||
return [], str(e)
|
||
return conversation.load_articles(ticket_id), None
|
||
|
||
|
||
class PrefixMiddleware:
|
||
"""Setzt SCRIPT_NAME, damit url_for hinter dem Apache-Subpfad stimmt."""
|
||
|
||
def __init__(self, app, prefix=""):
|
||
self.app = app
|
||
self.prefix = prefix
|
||
|
||
def __call__(self, environ, start_response):
|
||
if self.prefix:
|
||
environ["SCRIPT_NAME"] = self.prefix
|
||
path = environ.get("PATH_INFO", "")
|
||
if path.startswith(self.prefix):
|
||
environ["PATH_INFO"] = path[len(self.prefix):]
|
||
return self.app(environ, start_response)
|
||
|
||
|
||
def create_app() -> Flask:
|
||
app = Flask(__name__, template_folder="../templates", static_folder="../static")
|
||
app.secret_key = "episupport-local" # nur für Flash-Messages, keine Logins
|
||
app.config["MAX_CONTENT_LENGTH"] = 25 * 1024 * 1024 # max. 25 MB Upload
|
||
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config.URL_PREFIX)
|
||
db.init_db()
|
||
|
||
# ------------------------------------------------------------- Filter
|
||
@app.template_filter("dt")
|
||
def _dt(value):
|
||
"""UTC-Zeitstempel in lokaler Zeitzone (Standard Europe/Berlin) anzeigen."""
|
||
if not value:
|
||
return "–"
|
||
try:
|
||
s = str(value).replace("Z", "+00:00")
|
||
d = datetime.fromisoformat(s)
|
||
if d.tzinfo is None:
|
||
d = d.replace(tzinfo=timezone.utc)
|
||
return d.astimezone(_DISPLAY_TZ).strftime("%d.%m.%Y %H:%M")
|
||
except (ValueError, TypeError):
|
||
return str(value).replace("T", " ")[:16]
|
||
|
||
@app.context_processor
|
||
def _inject():
|
||
return {"zammad_url": config.ZAMMAD_URL}
|
||
|
||
# ------------------------------------------------------------- Routen
|
||
# erlaubte Sortierspalten (Anzeigename -> DB-Spalte)
|
||
SORT_COLS = {
|
||
"number": "number", "title": "title", "state": "state",
|
||
"priority": "priority_id", "owner": "owner",
|
||
"articles": "article_count", "updated": "zammad_updated_at",
|
||
"created": "zammad_created_at",
|
||
}
|
||
|
||
@app.route("/")
|
||
def index():
|
||
state = request.args.get("state", "")
|
||
sort = request.args.get("sort", "updated")
|
||
if sort not in SORT_COLS:
|
||
sort = "updated"
|
||
direction = "asc" if request.args.get("dir") == "asc" else "desc"
|
||
show_archived = request.args.get("archived") == "1"
|
||
|
||
with db.get_conn() as conn:
|
||
params = []
|
||
conds = []
|
||
if state == "not_closed":
|
||
conds.append("state NOT IN ('closed','merged','removed')")
|
||
elif state:
|
||
conds.append("state = ?")
|
||
params.append(state)
|
||
# abgelegte ("wirklich erledigte") Tickets standardmäßig ausblenden
|
||
if not show_archived:
|
||
conds.append("archived = 0")
|
||
where = ("WHERE " + " AND ".join(conds)) if conds else ""
|
||
|
||
# Tickets mit unbestätigten (ungelesenen) Änderungen
|
||
unseen_map = {
|
||
r["ticket_id"]: r["c"] for r in conn.execute(
|
||
"SELECT ticket_id, COUNT(*) c FROM changes WHERE seen=0 "
|
||
"GROUP BY ticket_id"
|
||
).fetchall()
|
||
}
|
||
|
||
col = SORT_COLS[sort]
|
||
# Tickets mit offenen Änderungen immer zuerst, dann nach Sortierwahl
|
||
order = (f'CASE WHEN id IN (SELECT DISTINCT ticket_id FROM changes '
|
||
f'WHERE seen=0) THEN 0 ELSE 1 END, {col} {direction.upper()}')
|
||
tickets = conn.execute(
|
||
f"SELECT * FROM tickets {where} ORDER BY {order}", params
|
||
).fetchall()
|
||
|
||
states = conn.execute(
|
||
"SELECT state, COUNT(*) c FROM tickets GROUP BY state ORDER BY c DESC"
|
||
).fetchall()
|
||
stats = {
|
||
"total": conn.execute("SELECT COUNT(*) c FROM tickets").fetchone()["c"],
|
||
"open": conn.execute(
|
||
"SELECT COUNT(*) c FROM tickets WHERE state NOT IN ('closed','merged','removed')"
|
||
).fetchone()["c"],
|
||
"unseen": conn.execute(
|
||
"SELECT COUNT(*) c FROM changes WHERE seen=0"
|
||
).fetchone()["c"],
|
||
}
|
||
recent = conn.execute(
|
||
"SELECT * FROM changes WHERE seen=0 ORDER BY detected_at DESC LIMIT 8"
|
||
).fetchall()
|
||
last_run = conn.execute(
|
||
"SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
archived_count = conn.execute(
|
||
"SELECT COUNT(*) c FROM tickets WHERE archived=1"
|
||
).fetchone()["c"]
|
||
return render_template("index.html", tickets=tickets, states=states,
|
||
stats=stats, recent=recent, last_run=last_run,
|
||
active_state=state, sort=sort, dir=direction,
|
||
unseen_map=unseen_map, show_archived=show_archived,
|
||
archived_count=archived_count)
|
||
|
||
@app.route("/ticket/<int:ticket_id>")
|
||
def ticket_detail(ticket_id):
|
||
with db.get_conn() as conn:
|
||
ticket = conn.execute(
|
||
"SELECT * FROM tickets WHERE id=?", (ticket_id,)
|
||
).fetchone()
|
||
if ticket is None:
|
||
abort(404)
|
||
timeline = conn.execute(
|
||
"SELECT * FROM changes WHERE ticket_id=? ORDER BY detected_at DESC",
|
||
(ticket_id,)
|
||
).fetchall()
|
||
conversation, conv_error = _load_conversation(ticket_id)
|
||
return render_template("ticket.html", ticket=ticket, timeline=timeline,
|
||
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>/archive", methods=["POST"])
|
||
def archive_ticket(ticket_id):
|
||
with db.get_conn() as conn:
|
||
conn.execute("UPDATE tickets SET archived=1 WHERE id=?", (ticket_id,))
|
||
flash("Ticket als wirklich erledigt abgelegt (ausgeblendet).")
|
||
# von der Detailseite zurück zur Übersicht, sonst zur Referrer-Seite
|
||
ref = request.referrer or ""
|
||
if f"/ticket/{ticket_id}" in ref:
|
||
return redirect(url_for("index"))
|
||
return redirect(ref or url_for("index"))
|
||
|
||
@app.route("/ticket/<int:ticket_id>/unarchive", methods=["POST"])
|
||
def unarchive_ticket(ticket_id):
|
||
with db.get_conn() as conn:
|
||
conn.execute("UPDATE tickets SET archived=0 WHERE id=?", (ticket_id,))
|
||
flash("Ticket wieder eingeblendet.")
|
||
return redirect(request.referrer or 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):
|
||
meta = conversation.find_attachment(article_id, att_id)
|
||
if not meta or meta["id"] is None:
|
||
abort(404)
|
||
try:
|
||
data = conversation.get_attachment_bytes(
|
||
_zammad(), ticket_id, article_id, att_id)
|
||
except ZammadError:
|
||
abort(502)
|
||
return Response(
|
||
data,
|
||
mimetype=meta["mime"] or "application/octet-stream",
|
||
headers={"Content-Disposition":
|
||
f'inline; filename="{meta["filename"]}"'},
|
||
)
|
||
|
||
@app.route("/ticket/<int:ticket_id>/refresh", methods=["POST"])
|
||
def refresh_conversation(ticket_id):
|
||
try:
|
||
conversation.store_articles(_zammad(), ticket_id, timeout=25)
|
||
flash("Konversation aktualisiert.")
|
||
except ZammadError as e:
|
||
flash("Aktualisierung fehlgeschlagen: " + str(e))
|
||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||
|
||
# ----------------------------------------------------- Antworten (live)
|
||
@app.route("/ticket/<int:ticket_id>/reply", methods=["POST"])
|
||
def reply_preview(ticket_id):
|
||
body = (request.form.get("body") or "").strip()
|
||
with db.get_conn() as conn:
|
||
ticket = conn.execute("SELECT number, title FROM tickets WHERE id=?",
|
||
(ticket_id,)).fetchone()
|
||
if not body:
|
||
flash("Bitte zuerst einen Text eingeben.")
|
||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||
uploads.cleanup_stale()
|
||
token, att_names = uploads.save_pending(request.files.getlist("files"))
|
||
return render_template(
|
||
"confirm.html",
|
||
heading="Antwort an EPI senden?",
|
||
intro=f"Diese Antwort wird live an den EPI-Support zu Ticket "
|
||
f"#{ticket['number'] if ticket else ticket_id} übermittelt:",
|
||
preview=body,
|
||
attachments=att_names,
|
||
action_url=url_for("reply_send", ticket_id=ticket_id),
|
||
cancel_url=url_for("ticket_detail", ticket_id=ticket_id),
|
||
fields={"body": body, "token": token or ""},
|
||
)
|
||
|
||
@app.route("/ticket/<int:ticket_id>/reply/send", methods=["POST"])
|
||
def reply_send(ticket_id):
|
||
body = (request.form.get("body") or "").strip()
|
||
token = request.form.get("token") or ""
|
||
if not body:
|
||
uploads.discard(token)
|
||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||
try:
|
||
_zammad().create_article(ticket_id, body, uploads.load_attachments(token))
|
||
_sync_own_action(ticket_id)
|
||
flash("✓ Antwort wurde an EPI gesendet.")
|
||
except ZammadError as e:
|
||
flash("✗ Fehler beim Senden: " + str(e))
|
||
finally:
|
||
uploads.discard(token)
|
||
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
|
||
|
||
# -------------------------------------------------- Neues Ticket (live)
|
||
def _known_groups():
|
||
with db.get_conn() as conn:
|
||
rows = conn.execute(
|
||
'SELECT DISTINCT "group" g FROM tickets WHERE "group" IS NOT NULL '
|
||
'ORDER BY g').fetchall()
|
||
return [r["g"] for r in rows] or ["EPI::Support"]
|
||
|
||
@app.route("/new")
|
||
def new_ticket_form():
|
||
return render_template("new_ticket.html", groups=_known_groups())
|
||
|
||
@app.route("/new/preview", methods=["POST"])
|
||
def new_ticket_preview():
|
||
title = (request.form.get("title") or "").strip()
|
||
group = (request.form.get("group") or "").strip()
|
||
body = (request.form.get("body") or "").strip()
|
||
if not (title and group and body):
|
||
flash("Bitte Titel, Gruppe und Nachricht ausfüllen.")
|
||
return redirect(url_for("new_ticket_form"))
|
||
uploads.cleanup_stale()
|
||
token, att_names = uploads.save_pending(request.files.getlist("files"))
|
||
return render_template(
|
||
"confirm.html",
|
||
heading="Neues Ticket bei EPI anlegen?",
|
||
intro=f"Es wird ein neues Ticket in der Gruppe „{group}“ mit dem "
|
||
f"Titel „{title}“ live beim EPI-Support angelegt:",
|
||
preview=body,
|
||
attachments=att_names,
|
||
action_url=url_for("new_ticket_send"),
|
||
cancel_url=url_for("new_ticket_form"),
|
||
fields={"title": title, "group": group, "body": body, "token": token or ""},
|
||
)
|
||
|
||
@app.route("/new/send", methods=["POST"])
|
||
def new_ticket_send():
|
||
title = (request.form.get("title") or "").strip()
|
||
group = (request.form.get("group") or "").strip()
|
||
body = (request.form.get("body") or "").strip()
|
||
token = request.form.get("token") or ""
|
||
if not (title and group and body):
|
||
uploads.discard(token)
|
||
return redirect(url_for("new_ticket_form"))
|
||
try:
|
||
res = _zammad().create_ticket(title, group, body,
|
||
uploads.load_attachments(token))
|
||
flash(f"✓ Ticket #{res.get('number')} wurde bei EPI angelegt.")
|
||
tid = res.get("id")
|
||
if tid:
|
||
_sync_own_action(tid)
|
||
return redirect(url_for("ticket_detail", ticket_id=tid))
|
||
except ZammadError as e:
|
||
flash("✗ Fehler beim Anlegen: " + str(e))
|
||
finally:
|
||
uploads.discard(token)
|
||
return redirect(url_for("new_ticket_form"))
|
||
|
||
@app.route("/changes")
|
||
def changes():
|
||
show = request.args.get("show", "all")
|
||
with db.get_conn() as conn:
|
||
if show == "unseen":
|
||
rows = conn.execute(
|
||
"SELECT * FROM changes WHERE seen=0 ORDER BY detected_at DESC LIMIT 300"
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"SELECT * FROM changes ORDER BY detected_at DESC LIMIT 300"
|
||
).fetchall()
|
||
unseen = conn.execute(
|
||
"SELECT COUNT(*) c FROM changes WHERE seen=0").fetchone()["c"]
|
||
return render_template("changes.html", rows=rows, show=show, unseen=unseen)
|
||
|
||
@app.route("/changes/mark-read", methods=["POST"])
|
||
def mark_read():
|
||
with db.get_conn() as conn:
|
||
conn.execute("UPDATE changes SET seen=1 WHERE seen=0")
|
||
flash("Alle Änderungen als gelesen markiert.")
|
||
return redirect(request.referrer or url_for("index"))
|
||
|
||
@app.route("/changes/<int:change_id>/seen", methods=["POST"])
|
||
def mark_one_read(change_id):
|
||
with db.get_conn() as conn:
|
||
conn.execute("UPDATE changes SET seen=1 WHERE id=?", (change_id,))
|
||
return redirect(request.referrer or url_for("index"))
|
||
|
||
PERIODS = [("30", "30 Tage"), ("90", "90 Tage"), ("180", "6 Monate"),
|
||
("365", "12 Monate"), ("all", "gesamt")]
|
||
|
||
def _period_days():
|
||
p = request.args.get("period", "365")
|
||
if p == "all":
|
||
return None, "all"
|
||
try:
|
||
return int(p), p
|
||
except ValueError:
|
||
return 365, "365"
|
||
|
||
@app.route("/analytics")
|
||
def analytics_view():
|
||
days, active = _period_days()
|
||
m = analytics.compute(days)
|
||
chart_svg = charts.monthly_chart(m["monthly"])
|
||
return render_template("analytics.html", m=m, chart_svg=chart_svg,
|
||
periods=PERIODS, active_period=active, h=m["humanize"])
|
||
|
||
@app.route("/export.xlsx")
|
||
def export_xlsx():
|
||
days, active = _period_days()
|
||
data = export.build_workbook(days)
|
||
fname = f"epi-support-auswertung-{active}.xlsx"
|
||
return Response(
|
||
data,
|
||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||
)
|
||
|
||
@app.route("/health")
|
||
def health():
|
||
with db.get_conn() as conn:
|
||
last = conn.execute(
|
||
"SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
if last is None:
|
||
return {"status": "starting"}, 200
|
||
return {
|
||
"status": last["status"],
|
||
"last_run": last["finished_at"],
|
||
"tickets": last["ticket_count"],
|
||
}, 200
|
||
|
||
return app
|
||
|
||
|
||
app = create_app()
|