first commit
This commit is contained in:
+230
@@ -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()
|
||||
Reference in New Issue
Block a user