- Poll the public download page for "Aktuelle Version <nr> vom <date>", report changes through the normal change feed and notification mail, and show the current version as a card on the overview. Runs on its own interval (default 30 min); the first run only records a baseline. - Archive epirentServer.zip / epirentClient.zip per version number so we keep a download cache and history: streamed to disk with SHA-256 and size, fetched in a background thread so ticket polling is unaffected, with a free-space check before downloading (~1.1 GB per version). - Retention is configurable via EPIRENT_DOWNLOAD_KEEP, where 0 means unlimited; a successful archive is reported as a change and by mail. - New Downloads page listing the archived versions with checksums and links to fetch the cached files. - Notification lines now omit the arrow when there is no previous value and drop the "#" for entries without a ticket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""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, downloads, epirent, 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 _archive_downloads() -> None:
|
||
"""Download-Pakete der aktuellen EPIRent-Version sichern (im Hintergrund)."""
|
||
if not config.EPIRENT_DOWNLOAD_ENABLED:
|
||
return
|
||
cur = epirent.current()
|
||
if not cur or not cur.get("version"):
|
||
return
|
||
if downloads.is_complete(cur["version"]):
|
||
return
|
||
downloads.archive_async(cur["version"])
|
||
|
||
|
||
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)
|
||
# EPIRent-Version auf der Download-Seite prüfen (eigenes Intervall)
|
||
version_change = epirent.check()
|
||
if version_change:
|
||
all_changes.append(version_change)
|
||
# Pakete dieser Version im Hintergrund archivieren, falls noch nicht da
|
||
_archive_downloads()
|
||
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()
|