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:
+101
@@ -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
|
||||
Reference in New Issue
Block a user