Files
EpiSupport-Zammad-Scraper/app/poller.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

271 lines
9.8 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.
"""Hintergrund-Abruf: Tickets holen, mit letztem Stand vergleichen,
Änderungen protokollieren und ggf. benachrichtigen."""
import json
import logging
import time
from datetime import datetime, timezone
from . import config, conversation, db, notify
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.poller")
# Felder, deren Änderung wir festhalten: db-Spalte -> Anzeigename
TRACKED_FIELDS = {
"state": "Status",
"priority": "Priorität",
"owner": "Bearbeiter",
"group": "Gruppe",
"title": "Titel",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _map_ticket(t: dict) -> dict:
"""Zammad-Ticket -> unsere Spalten."""
return {
"id": t["id"],
"number": t.get("number"),
"title": t.get("title"),
"state": t.get("state"),
"state_id": t.get("state_id"),
"priority": t.get("priority"),
"priority_id": t.get("priority_id"),
"owner": t.get("owner"),
"owner_id": t.get("owner_id"),
"group": t.get("group"),
"customer": t.get("customer"),
"article_count": t.get("article_count"),
"zammad_created_at": t.get("created_at"),
"zammad_updated_at": t.get("updated_at"),
"last_contact_at": t.get("last_contact_at"),
"last_contact_agent_at": t.get("last_contact_agent_at"),
"last_contact_customer_at": t.get("last_contact_customer_at"),
"close_at": t.get("close_at"),
"last_close_at": t.get("last_close_at"),
"raw_json": json.dumps(t, ensure_ascii=False),
}
_COLS = [
"id", "number", "title", "state", "state_id", "priority", "priority_id",
"owner", "owner_id", "group", "customer", "article_count",
"zammad_created_at", "zammad_updated_at", "last_contact_at",
"last_contact_agent_at", "last_contact_customer_at", "close_at",
"last_close_at", "raw_json",
]
def _upsert(conn, row: dict, is_new: bool) -> None:
now = _now()
cols = list(_COLS)
quoted = [f'"{c}"' if c == "group" else c for c in cols]
placeholders = ", ".join(["?"] * len(cols))
values = [row[c] for c in cols]
if is_new:
conn.execute(
f"INSERT INTO tickets ({', '.join(quoted)}, first_seen_at, last_synced_at) "
f"VALUES ({placeholders}, ?, ?)",
values + [now, now],
)
else:
assignments = ", ".join(f"{q}=?" for q in quoted)
conn.execute(
f"UPDATE tickets SET {assignments}, last_synced_at=? WHERE id=?",
values + [now, row["id"]],
)
def _record_change(conn, ticket, field, label, old, new, detail=None) -> dict:
now = _now()
conn.execute(
"INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, "
"label, old_value, new_value, detail, detected_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(ticket["id"], ticket.get("number"), ticket.get("title"), field,
label, old, new, detail, now),
)
return {
"ticket_number": ticket.get("number"),
"ticket_title": ticket.get("title"),
"field": field, "label": label,
"old_value": old, "new_value": new, "detail": detail,
}
def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
"""Vergleicht ein frisches Ticket mit dem gespeicherten Stand."""
found: list[dict] = []
for field, label in TRACKED_FIELDS.items():
old_val = old_row[field]
new_val = new_t.get(field)
if (old_val or "") != (new_val or ""):
found.append(_record_change(conn, new_t, field, label, old_val, new_val))
# Neue Artikel/Antworten (article_count gestiegen)
old_count = old_row["article_count"] or 0
new_count = new_t.get("article_count") or 0
if new_count > old_count:
detail = None
try:
articles = client.ticket_articles(new_t["id"])
conversation.save_articles(new_t["id"], articles, conn=conn) # cachen
except ZammadError as e:
log.warning("Artikel für Ticket %s nicht abrufbar: %s", new_t["id"], e)
articles = []
if articles:
last = articles[-1]
sender = last.get("sender") # Agent / Customer / System
frm = last.get("from") or last.get("created_by") or ""
subject = (last.get("subject") or "").strip()
body = (last.get("body") or "")
preview = body.replace("\n", " ").strip()
if len(preview) > 200:
preview = preview[:200] + "…"
detail = f"{sender or 'Antwort'} von {frm}"
if subject:
detail += f" {subject}“"
if preview:
detail += f": {preview}"
found.append(_record_change(
conn, new_t, "article", "Neue Antwort/Notiz",
str(old_count), str(new_count), detail))
return found
def sync_ticket(client: ZammadClient, ticket_id: int) -> None:
"""Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen.
Wird nach einer EIGENEN Aktion (Antwort/Neuanlage über den Client) gerufen,
damit der nächste Poll-Durchlauf keinen Unterschied sieht und die selbst
ausgelöste Änderung weder im Feed erscheint noch eine Mail auslöst.
"""
t = client.get_ticket(ticket_id)
with db.get_conn() as conn:
existing = conn.execute(
"SELECT id FROM tickets WHERE id=?", (ticket_id,)
).fetchone()
_upsert(conn, _map_ticket(t), is_new=(existing is None))
try:
conversation.store_articles(client, ticket_id)
except ZammadError as e:
log.warning("Artikel-Sync nach eigener Aktion (Ticket %s): %s", ticket_id, e)
def _backfill_articles(client: ZammadClient, limit: int = 10) -> None:
"""Pro Durchlauf einige Tickets ohne gespeicherte Konversation nachladen,
damit die Leseansicht nach und nach komplett aus der DB bedient wird."""
for tid in conversation.tickets_without_articles(limit):
try:
conversation.store_articles(client, tid)
except ZammadError as e:
log.warning("Backfill für Ticket %s fehlgeschlagen: %s", tid, e)
def poll_once(client: ZammadClient) -> int:
"""Ein Abruf-Durchlauf. Gibt Anzahl erkannter Änderungen zurück."""
started = _now()
status = "ok"
error = None
all_changes: list[dict] = []
ticket_count = 0
try:
tickets = client.list_tickets()
ticket_count = len(tickets)
with db.get_conn() as conn:
for t in tickets:
row = _map_ticket(t)
existing = conn.execute(
"SELECT * FROM tickets WHERE id=?", (t["id"],)
).fetchone()
if existing is None:
_upsert(conn, row, is_new=True)
# Neues Ticket nur dann als "Änderung" melden, wenn die DB
# nicht gerade frisch initialisiert wird (siehe unten).
all_changes.append(_record_change(
conn, t, "new_ticket", "Neues Ticket", None,
f"#{t.get('number')}", None))
try:
conversation.store_articles(client, t["id"], conn=conn)
except ZammadError:
pass
else:
all_changes.extend(_detect_changes(conn, client, t, existing))
_upsert(conn, row, is_new=False)
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
_backfill_articles(client)
except ZammadError as e:
status = "error"
error = str(e)
log.error("Abruf fehlgeschlagen: %s", e)
except Exception as e: # noqa: BLE001
status = "error"
error = repr(e)
log.exception("Unerwarteter Fehler beim Abruf")
finished = _now()
with db.get_conn() as conn:
conn.execute(
"INSERT INTO sync_runs (started_at, finished_at, ticket_count, "
"change_count, status, error) VALUES (?,?,?,?,?,?)",
(started, finished, ticket_count, len(all_changes), status, error),
)
if all_changes:
log.info("%d Änderung(en) erkannt.", len(all_changes))
try:
notify.send_change_mail(all_changes)
with db.get_conn() as conn:
conn.execute("UPDATE changes SET notified=1 WHERE notified=0")
except Exception: # noqa: BLE001
log.exception("Benachrichtigung fehlgeschlagen")
return len(all_changes)
def _is_first_run() -> bool:
with db.get_conn() as conn:
n = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone()["c"]
return n == 0
def run_forever() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
db.init_db()
client = ZammadClient()
first = _is_first_run()
if first:
log.info("Erster Lauf: lege Basis-Snapshot an (ohne Änderungsmeldungen).")
# Beim allerersten Lauf nur Snapshot speichern, sonst würden alle
# bestehenden Tickets als "neu" gemeldet.
try:
tickets = client.list_tickets()
with db.get_conn() as conn:
for t in tickets:
_upsert(conn, _map_ticket(t), is_new=True)
conn.execute("DELETE FROM changes") # Basislauf erzeugt keine Meldungen
log.info("Basis-Snapshot mit %d Tickets gespeichert.", len(tickets))
except Exception: # noqa: BLE001
log.exception("Basis-Snapshot fehlgeschlagen, versuche es im Intervall erneut.")
log.info("Poller läuft, Intervall %ds.", config.POLL_INTERVAL_SECONDS)
while True:
try:
poll_once(client)
except Exception: # noqa: BLE001
log.exception("poll_once-Schleife fing Ausnahme ab")
time.sleep(config.POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
run_forever()