Files
EpiSupport-Zammad-Scraper/app/poller.py
T
leopold.stroblandClaude Opus 4.8 528d66b2b0 Add "truly done" ticket archiving with auto-unhide
- Let the user mark closed tickets as really done and hide them, so
  tickets closed prematurely by an agent stay visible until confirmed
- User-side "archived" flag stored in the DB (independent of Zammad),
  added via a schema migration on existing databases
- Hide archived tickets from the overview by default with an
  "erledigte einblenden" toggle; archive/unarchive from the ticket
  detail page and via a per-row quick action
- Poller automatically un-hides an archived ticket when new activity is
  detected, so nothing is missed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:34:09 +02:00

276 lines
10 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:
ch = _detect_changes(conn, client, t, existing)
_upsert(conn, row, is_new=False)
if ch:
# Neue Aktivität -> ggf. abgelegtes Ticket wieder einblenden
conn.execute(
"UPDATE tickets SET archived=0 WHERE id=?", (t["id"],))
all_changes.extend(ch)
# 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()