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:
2026-07-01 09:19:53 +02:00
co-authored by Claude Opus 4.8
parent da2f7c3c86
commit 1041f283a4
17 changed files with 600 additions and 57 deletions
+42 -2
View File
@@ -5,7 +5,7 @@ import logging
import time
from datetime import datetime, timezone
from . import config, db, notify
from . import config, conversation, db, notify
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.poller")
@@ -111,7 +111,12 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
new_count = new_t.get("article_count") or 0
if new_count > old_count:
detail = None
articles = client.ticket_articles(new_t["id"])
try:
articles = client.ticket_articles(new_t["id"])
conversation.save_articles(new_t["id"], articles, conn=conn) # cachen
except ZammadError as e:
log.warning("Artikel für Ticket %s nicht abrufbar: %s", new_t["id"], e)
articles = []
if articles:
last = articles[-1]
sender = last.get("sender") # Agent / Customer / System
@@ -133,6 +138,35 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
return found
def sync_ticket(client: ZammadClient, ticket_id: int) -> None:
"""Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen.
Wird nach einer EIGENEN Aktion (Antwort/Neuanlage über den Client) gerufen,
damit der nächste Poll-Durchlauf keinen Unterschied sieht und die selbst
ausgelöste Änderung weder im Feed erscheint noch eine Mail auslöst.
"""
t = client.get_ticket(ticket_id)
with db.get_conn() as conn:
existing = conn.execute(
"SELECT id FROM tickets WHERE id=?", (ticket_id,)
).fetchone()
_upsert(conn, _map_ticket(t), is_new=(existing is None))
try:
conversation.store_articles(client, ticket_id)
except ZammadError as e:
log.warning("Artikel-Sync nach eigener Aktion (Ticket %s): %s", ticket_id, e)
def _backfill_articles(client: ZammadClient, limit: int = 10) -> None:
"""Pro Durchlauf einige Tickets ohne gespeicherte Konversation nachladen,
damit die Leseansicht nach und nach komplett aus der DB bedient wird."""
for tid in conversation.tickets_without_articles(limit):
try:
conversation.store_articles(client, tid)
except ZammadError as e:
log.warning("Backfill für Ticket %s fehlgeschlagen: %s", tid, e)
def poll_once(client: ZammadClient) -> int:
"""Ein Abruf-Durchlauf. Gibt Anzahl erkannter Änderungen zurück."""
started = _now()
@@ -157,9 +191,15 @@ def poll_once(client: ZammadClient) -> int:
all_changes.append(_record_change(
conn, t, "new_ticket", "Neues Ticket", None,
f"#{t.get('number')}", None))
try:
conversation.store_articles(client, t["id"], conn=conn)
except ZammadError:
pass
else:
all_changes.extend(_detect_changes(conn, client, t, existing))
_upsert(conn, row, is_new=False)
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
_backfill_articles(client)
except ZammadError as e:
status = "error"
error = str(e)