Add EPI-Support monitoring & analytics platform
Internal Flask app that monitors the epi-helpdesk.zammad.com tickets via the Zammad API, deployable as a systemd service behind Apache under /episupport. Features: - Poller: session-login API sync, snapshot diff to detect agent changes (status, owner, priority, closures, new replies); email notifications - Dashboard: ticket overview with sorting, "not closed" filter and highlighting of tickets with unconfirmed changes; per-item and bulk "mark as read" - Analytics: KPIs, response/resolution times, monthly SVG trend chart, distributions (status/priority/group/agent), Excel export - Conversation view: full per-ticket thread cached in the DB (avoids proxy timeouts), inline image attachments, safe HTML-to-text rendering - Write actions: reply to tickets and create new tickets with a confirmation step and attachment upload (incl. clipboard paste); self-triggered actions are synced back so they aren't flagged as changes - Local timezone display (Europe/Berlin) for all timestamps Includes deploy tooling: setup.sh, systemd units and Apache config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+44
-20
@@ -58,16 +58,17 @@ class ZammadClient:
|
||||
self._login_session()
|
||||
|
||||
# ------------------------------------------------------------- requests
|
||||
def _get(self, path: str, params: dict | None = None, _retry: bool = True):
|
||||
def _get(self, path: str, params: dict | None = None, _retry: bool = True,
|
||||
timeout: int | None = None):
|
||||
self.ensure_auth()
|
||||
url = f"{self.base}{path}"
|
||||
r = self.session.get(url, params=params, timeout=60)
|
||||
r = self.session.get(url, params=params, timeout=timeout or 60)
|
||||
if r.status_code in (401, 403) and _retry and not config.ZAMMAD_TOKEN:
|
||||
# Session evtl. abgelaufen -> einmal neu einloggen
|
||||
log.warning("Auth abgelaufen (HTTP %s), erneuter Login.", r.status_code)
|
||||
self._authed = False
|
||||
self._login_session()
|
||||
return self._get(path, params=params, _retry=False)
|
||||
return self._get(path, params=params, _retry=False, timeout=timeout)
|
||||
if r.status_code != 200:
|
||||
raise ZammadError(f"GET {path} -> HTTP {r.status_code}: {r.text[:200]}")
|
||||
return r.json()
|
||||
@@ -97,12 +98,27 @@ class ZammadClient:
|
||||
break
|
||||
return out
|
||||
|
||||
def ticket_articles(self, ticket_id: int) -> list[dict]:
|
||||
try:
|
||||
return self._get(f"/api/v1/ticket_articles/by_ticket/{ticket_id}")
|
||||
except ZammadError as e:
|
||||
log.warning("Artikel für Ticket %s nicht abrufbar: %s", ticket_id, e)
|
||||
return []
|
||||
def get_ticket(self, ticket_id: int) -> dict:
|
||||
return self._get(f"/api/v1/tickets/{ticket_id}", params={"expand": "true"})
|
||||
|
||||
def ticket_articles(self, ticket_id: int, timeout: int | None = None) -> list[dict]:
|
||||
return self._get(f"/api/v1/ticket_articles/by_ticket/{ticket_id}",
|
||||
timeout=timeout)
|
||||
|
||||
def get_attachment(self, ticket_id: int, article_id: int, att_id: int,
|
||||
timeout: int = 30) -> bytes:
|
||||
"""Rohbytes eines Anhangs herunterladen."""
|
||||
self.ensure_auth()
|
||||
url = (f"{self.base}/api/v1/ticket_attachment/"
|
||||
f"{ticket_id}/{article_id}/{att_id}")
|
||||
r = self.session.get(url, timeout=timeout)
|
||||
if r.status_code in (401, 403) and not config.ZAMMAD_TOKEN:
|
||||
self._authed = False
|
||||
self._login_session()
|
||||
r = self.session.get(url, timeout=timeout)
|
||||
if r.status_code != 200:
|
||||
raise ZammadError(f"Anhang {att_id} -> HTTP {r.status_code}")
|
||||
return r.content
|
||||
|
||||
# ----------------------------------------------------------- schreibend
|
||||
def _csrf(self) -> str | None:
|
||||
@@ -128,26 +144,34 @@ class ZammadClient:
|
||||
raise ZammadError(f"POST {path} -> HTTP {r.status_code}: {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def create_article(self, ticket_id: int, body: str) -> dict:
|
||||
def create_article(self, ticket_id: int, body: str,
|
||||
attachments: list[dict] | None = None) -> dict:
|
||||
"""Antwort/Nachricht an ein bestehendes Ticket anhängen (an EPI sichtbar)."""
|
||||
return self._post("/api/v1/ticket_articles", {
|
||||
payload = {
|
||||
"ticket_id": ticket_id,
|
||||
"body": body,
|
||||
"content_type": "text/plain",
|
||||
"type": "web",
|
||||
"internal": False,
|
||||
})
|
||||
}
|
||||
if attachments:
|
||||
payload["attachments"] = attachments
|
||||
return self._post("/api/v1/ticket_articles", payload)
|
||||
|
||||
def create_ticket(self, title: str, group: str, body: str) -> dict:
|
||||
def create_ticket(self, title: str, group: str, body: str,
|
||||
attachments: list[dict] | None = None) -> dict:
|
||||
"""Neues Ticket beim EPI-Support anlegen."""
|
||||
article = {
|
||||
"subject": title,
|
||||
"body": body,
|
||||
"content_type": "text/plain",
|
||||
"type": "web",
|
||||
"internal": False,
|
||||
}
|
||||
if attachments:
|
||||
article["attachments"] = attachments
|
||||
return self._post("/api/v1/tickets", {
|
||||
"title": title,
|
||||
"group": group,
|
||||
"article": {
|
||||
"subject": title,
|
||||
"body": body,
|
||||
"content_type": "text/plain",
|
||||
"type": "web",
|
||||
"internal": False,
|
||||
},
|
||||
"article": article,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user