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
+101
View File
@@ -0,0 +1,101 @@
"""Zwischenspeicher für hochgeladene Anhänge.
Beim Bestätigungs-Schritt werden Dateien temporär unter data/uploads/<token>
abgelegt und erst beim verbindlichen Senden gelesen und (base64) an Zammad
übergeben. Danach wird der Zwischenspeicher gelöscht.
"""
import base64
import json
import re
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from . import config
UPLOAD_DIR = config.DATA_DIR / "uploads"
UPLOAD_DIR.mkdir(exist_ok=True)
_TOKEN_RE = re.compile(r"^[0-9a-f]{32}$")
def _dir(token: str) -> Path | None:
if not token or not _TOKEN_RE.match(token):
return None
return UPLOAD_DIR / token
def save_pending(files) -> tuple[str | None, list[str]]:
"""Hochgeladene Dateien (werkzeug FileStorage) zwischenspeichern.
Gibt (token, Dateinamen) zurück; (None, []) wenn nichts dabei war."""
real = [f for f in files if f and getattr(f, "filename", "")]
if not real:
return None, []
token = uuid.uuid4().hex
d = UPLOAD_DIR / token
d.mkdir(parents=True, exist_ok=True)
manifest, names = [], []
for i, f in enumerate(real):
data = f.read()
if not data:
continue
(d / str(i)).write_bytes(data)
manifest.append({
"stored": str(i),
"filename": f.filename,
"mimetype": getattr(f, "mimetype", None) or "application/octet-stream",
"size": len(data),
})
names.append(f.filename)
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
return token, names
def _manifest(token: str) -> list[dict]:
d = _dir(token)
if not d:
return []
mf = d / "manifest.json"
if not mf.exists():
return []
return json.loads(mf.read_text(encoding="utf-8"))
def names(token: str) -> list[str]:
return [m["filename"] for m in _manifest(token)]
def load_attachments(token: str) -> list[dict]:
"""Zammad-Inline-Format: {filename, data(base64), mime-type}."""
d = _dir(token)
out = []
for m in _manifest(token):
p = d / m["stored"]
if not p.exists():
continue
out.append({
"filename": m["filename"],
"data": base64.b64encode(p.read_bytes()).decode("ascii"),
"mime-type": m["mimetype"],
})
return out
def discard(token: str) -> None:
d = _dir(token)
if d and d.exists():
shutil.rmtree(d, ignore_errors=True)
def cleanup_stale(max_age_seconds: int = 3600) -> None:
"""Verwaiste Zwischenspeicher (abgebrochene Vorgänge) aufräumen."""
if not UPLOAD_DIR.exists():
return
now = datetime.now(timezone.utc).timestamp()
for d in UPLOAD_DIR.iterdir():
try:
if d.is_dir() and (now - d.stat().st_mtime) > max_age_seconds:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass