Files
EpiSupport-Zammad-Scraper/app/poller.py
T
2026-06-29 09:52:02 +02:00

231 lines
8.0 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, 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()