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>
102 lines
2.9 KiB
Python
102 lines
2.9 KiB
Python
"""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
|