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:
@@ -0,0 +1,157 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user