first commit

This commit is contained in:
2026-06-29 09:52:02 +02:00
commit da2f7c3c86
31 changed files with 2306 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
"""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()