Add EPI-Support monitoring & analytics platform
Internal Flask app that monitors the epi-helpdesk.zammad.com tickets via the Zammad API, deployable as a systemd service behind Apache under /episupport. Features: - Poller: session-login API sync, snapshot diff to detect agent changes (status, owner, priority, closures, new replies); email notifications - Dashboard: ticket overview with sorting, "not closed" filter and highlighting of tickets with unconfirmed changes; per-item and bulk "mark as read" - Analytics: KPIs, response/resolution times, monthly SVG trend chart, distributions (status/priority/group/agent), Excel export - Conversation view: full per-ticket thread cached in the DB (avoids proxy timeouts), inline image attachments, safe HTML-to-text rendering - Write actions: reply to tickets and create new tickets with a confirmation step and attachment upload (incl. clipboard paste); self-triggered actions are synced back so they aren't flagged as changes - Local timezone display (Europe/Berlin) for all timestamps Includes deploy tooling: setup.sh, systemd units and Apache config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+85
-28
@@ -5,15 +5,23 @@ Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit
|
||||
Präfix erzeugt.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from flask import (Flask, Response, abort, flash, redirect, render_template,
|
||||
request, url_for)
|
||||
|
||||
from . import analytics, charts, config, db, export, textutil
|
||||
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
|
||||
|
||||
@@ -25,31 +33,28 @@ def _zammad():
|
||||
return _client
|
||||
|
||||
|
||||
def _load_conversation(ticket_id):
|
||||
"""Holt die Artikel eines Tickets live und bereitet sie auf.
|
||||
Gibt (articles, error) zurück."""
|
||||
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:
|
||||
raw = _zammad().ticket_articles(ticket_id)
|
||||
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)
|
||||
out = []
|
||||
for a in raw:
|
||||
body = a.get("body") or ""
|
||||
if (a.get("content_type") or "").startswith("text/html"):
|
||||
body = textutil.clean_html(body)
|
||||
atts = [x.get("filename") for x in (a.get("attachments") or [])
|
||||
if x.get("filename") and x.get("filename") != "message.html"]
|
||||
out.append({
|
||||
"id": a.get("id"),
|
||||
"sender": a.get("sender"), # Customer / Agent / System
|
||||
"frm": a.get("from") or "",
|
||||
"created_at": a.get("created_at"),
|
||||
"subject": a.get("subject"),
|
||||
"internal": a.get("internal"),
|
||||
"body": body,
|
||||
"attachments": atts,
|
||||
})
|
||||
return out, None
|
||||
return conversation.load_articles(ticket_id), None
|
||||
|
||||
|
||||
class PrefixMiddleware:
|
||||
@@ -71,15 +76,24 @@ class PrefixMiddleware:
|
||||
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 "–"
|
||||
return str(value).replace("T", " ")[:16]
|
||||
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():
|
||||
@@ -166,6 +180,32 @@ def create_app() -> Flask:
|
||||
return render_template("ticket.html", ticket=ticket, timeline=timeline,
|
||||
conversation=conversation, conv_error=conv_error)
|
||||
|
||||
@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):
|
||||
@@ -176,27 +216,35 @@ def create_app() -> Flask:
|
||||
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},
|
||||
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)
|
||||
_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)
|
||||
@@ -219,15 +267,18 @@ def create_app() -> Flask:
|
||||
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},
|
||||
fields={"title": title, "group": group, "body": body, "token": token or ""},
|
||||
)
|
||||
|
||||
@app.route("/new/send", methods=["POST"])
|
||||
@@ -235,16 +286,22 @@ def create_app() -> Flask:
|
||||
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)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user