Files
EpiSupport-Zammad-Scraper/app/conversation.py
T
leopold.stroblandClaude Opus 4.8 1041f283a4 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>
2026-07-01 09:19:53 +02:00

158 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Konversation (Artikel) eines Tickets: in der DB zwischenspeichern und laden.
So muss die Leseseite nicht live auf Zammad warten (vermeidet Proxy-Timeouts).
Der Poller füllt/aktualisiert die Artikel im Hintergrund; die Webseite liest
nur aus der DB (mit einmaligem Lazy-Load als Rückfall).
"""
import json
import logging
from datetime import datetime, timezone
from . import config, db, textutil
log = logging.getLogger("episupport.conversation")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
ATT_CACHE = config.DATA_DIR / "att_cache"
ATT_CACHE.mkdir(exist_ok=True)
def _attachment_meta(article) -> list[dict]:
"""Relevante Anhang-Metadaten (id, filename, mime) je Artikel."""
out = []
for x in (article.get("attachments") or []):
fn = x.get("filename")
if not fn or fn == "message.html":
continue
prefs = x.get("preferences") or {}
mime = prefs.get("Mime-Type") or prefs.get("Content-Type") or ""
out.append({"id": x.get("id"), "filename": fn, "mime": mime})
return out
def save_articles(ticket_id: int, articles: list[dict], conn=None) -> int:
"""Bereits abgerufene Artikel in die DB schreiben (INSERT OR REPLACE).
Wird `conn` übergeben, wird diese bestehende Verbindung genutzt (wichtig
im Poller, der bereits eine Transaktion offen hält sonst „database is
locked"). Ohne `conn` wird eine eigene, kurze Transaktion verwendet.
"""
now = _now()
def _write(c):
for a in articles:
c.execute(
"INSERT OR REPLACE INTO articles (article_id, ticket_id, sender, "
"frm, subject, body, content_type, internal, created_at, "
"attachments, fetched_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(a.get("id"), ticket_id, a.get("sender"), a.get("from"),
a.get("subject"), a.get("body"), a.get("content_type"),
1 if a.get("internal") else 0, a.get("created_at"),
json.dumps(_attachment_meta(a)), now),
)
if conn is not None:
_write(conn)
else:
with db.get_conn() as own:
_write(own)
return len(articles)
def store_articles(client, ticket_id: int, timeout: int | None = None,
conn=None) -> int:
"""Artikel live von Zammad holen und speichern."""
articles = client.ticket_articles(ticket_id, timeout=timeout)
return save_articles(ticket_id, articles, conn=conn)
def load_articles(ticket_id: int) -> list[dict]:
"""Aufbereitete Artikel aus der DB (HTML sicher zu Text bereinigt)."""
with db.get_conn() as conn:
rows = conn.execute(
"SELECT * FROM articles WHERE ticket_id=? ORDER BY created_at, article_id",
(ticket_id,)
).fetchall()
out = []
for r in rows:
body = r["body"] or ""
if (r["content_type"] or "").startswith("text/html"):
body = textutil.clean_html(body)
out.append({
"id": r["article_id"],
"sender": r["sender"],
"frm": r["frm"] or "",
"subject": r["subject"],
"created_at": r["created_at"],
"internal": r["internal"],
"body": body,
"attachments": _parse_attachments(r["attachments"]),
})
return out
def _parse_attachments(raw) -> list[dict]:
"""Verträgt altes Format (nur Dateinamen) und neues (id/filename/mime)."""
items = json.loads(raw or "[]")
out = []
for x in items:
if isinstance(x, str): # altes Cache-Format
out.append({"id": None, "filename": x, "mime": "", "is_image": False})
else:
mime = x.get("mime") or ""
out.append({
"id": x.get("id"),
"filename": x.get("filename"),
"mime": mime,
"is_image": mime.startswith("image/"),
})
return out
def find_attachment(article_id: int, att_id: int) -> dict | None:
"""Metadaten eines Anhangs (Mime/Dateiname) aus der DB für die Auslieferung."""
with db.get_conn() as conn:
row = conn.execute(
"SELECT attachments FROM articles WHERE article_id=?", (article_id,)
).fetchone()
if not row:
return None
for a in _parse_attachments(row["attachments"]):
if a["id"] == att_id:
return a
return None
def get_attachment_bytes(client, ticket_id: int, article_id: int, att_id: int) -> bytes:
"""Anhang-Bytes liefern; einmal von Zammad holen und auf Platte cachen."""
cache = ATT_CACHE / f"{article_id}_{att_id}"
if cache.exists():
return cache.read_bytes()
data = client.get_attachment(ticket_id, article_id, att_id)
try:
cache.write_bytes(data)
except OSError:
pass
return data
def has_articles(ticket_id: int) -> bool:
with db.get_conn() as conn:
return conn.execute(
"SELECT 1 FROM articles WHERE ticket_id=? LIMIT 1", (ticket_id,)
).fetchone() is not None
def tickets_without_articles(limit: int = 10) -> list[int]:
with db.get_conn() as conn:
rows = conn.execute(
"SELECT id FROM tickets WHERE id NOT IN "
"(SELECT DISTINCT ticket_id FROM articles) "
"ORDER BY zammad_updated_at DESC LIMIT ?", (limit,)
).fetchall()
return [r["id"] for r in rows]