"""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, 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 articles = client.ticket_articles(new_t["id"]) 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 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)) else: all_changes.extend(_detect_changes(conn, client, t, existing)) _upsert(conn, row, is_new=False) 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()