From da2f7c3c86e73030773f162043bf63a7b93416e1 Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Mon, 29 Jun 2026 09:52:02 +0200 Subject: [PATCH] first commit --- .env.example | 43 ++++ .gitignore | 6 + .vscode/sftp.json | 19 ++ README.md | 90 +++++++++ app/__init__.py | 0 app/analytics.py | 152 ++++++++++++++ app/charts.py | 72 +++++++ app/config.py | 44 +++++ app/db.py | 89 +++++++++ app/export.py | 112 +++++++++++ app/notify.py | 76 +++++++ app/poller.py | 230 ++++++++++++++++++++++ app/textutil.py | 51 +++++ app/web.py | 327 +++++++++++++++++++++++++++++++ app/zammad.py | 153 +++++++++++++++ deploy/episupport-apache.conf | 22 +++ deploy/episupport-poller.service | 23 +++ deploy/episupport-web.service | 28 +++ deploy/setup.sh | 134 +++++++++++++ requirements.txt | 5 + run_poller.py | 5 + static/style.css | 210 ++++++++++++++++++++ templates/_macros.html | 17 ++ templates/analytics.html | 87 ++++++++ templates/base.html | 36 ++++ templates/changes.html | 29 +++ templates/confirm.html | 21 ++ templates/index.html | 103 ++++++++++ templates/new_ticket.html | 30 +++ templates/ticket.html | 86 ++++++++ wsgi.py | 6 + 31 files changed, 2306 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vscode/sftp.json create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/analytics.py create mode 100644 app/charts.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/export.py create mode 100644 app/notify.py create mode 100644 app/poller.py create mode 100644 app/textutil.py create mode 100644 app/web.py create mode 100644 app/zammad.py create mode 100644 deploy/episupport-apache.conf create mode 100644 deploy/episupport-poller.service create mode 100644 deploy/episupport-web.service create mode 100644 deploy/setup.sh create mode 100644 requirements.txt create mode 100644 run_poller.py create mode 100644 static/style.css create mode 100644 templates/_macros.html create mode 100644 templates/analytics.html create mode 100644 templates/base.html create mode 100644 templates/changes.html create mode 100644 templates/confirm.html create mode 100644 templates/index.html create mode 100644 templates/new_ticket.html create mode 100644 templates/ticket.html create mode 100644 wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05d537e --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# ============================================================ +# EPI-Support – Konfiguration +# Diese Datei nach ".env" kopieren und ausfüllen. +# Die echte .env NIEMALS ins Git/Repo committen. +# ============================================================ + +# --- Zammad-Zugang ----------------------------------------- +ZAMMAD_URL=https://epi-helpdesk.zammad.com + +# Variante A (empfohlen, sobald verfügbar): persönlicher API-Token +# Profil -> Token-Zugriff in Zammad. Einzeln widerrufbar. +# Wenn gesetzt, wird Variante B ignoriert. +ZAMMAD_TOKEN= + +# Variante B: Login per Benutzer/Passwort (Session). +# Nötig, solange "API password access" auf der Instanz aus ist. +ZAMMAD_USER=support@vtmediagbr.freshdesk.com +ZAMMAD_PASSWORD= + +# --- Abruf -------------------------------------------------- +# Intervall des Hintergrund-Abrufs in Sekunden +POLL_INTERVAL_SECONDS=180 + +# --- Web ---------------------------------------------------- +# Pfad-Präfix hinter Apache (srhnweb/episupport) +EPISUPPORT_URL_PREFIX=/episupport +# Port, auf dem gunicorn intern lauscht (Apache proxyt darauf) +EPISUPPORT_PORT=8071 + +# --- E-Mail-Benachrichtigung (optional) --------------------- +NOTIFY_ENABLED=true +SMTP_HOST=mail.your-server.de +# Port 587 => STARTTLS (SMTP_STARTTLS=true, SMTP_SSL=false) +# Port 465 => SSL/TLS (SMTP_SSL=true) +# Port 25 => STARTTLS (veraltet) +SMTP_PORT=587 +SMTP_STARTTLS=true +SMTP_SSL=false +SMTP_USER=episupportportal@urlpoint.de +SMTP_PASSWORD= +SMTP_FROM=episupportportal@urlpoint.de +# Mehrere Empfänger mit Komma trennen +NOTIFY_TO=leopold.strobl@vt-media.de diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9344727 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +venv/ +data/ +__pycache__/ +*.pyc +.claude/ \ No newline at end of file diff --git a/.vscode/sftp.json b/.vscode/sftp.json new file mode 100644 index 0000000..0b8044e --- /dev/null +++ b/.vscode/sftp.json @@ -0,0 +1,19 @@ +{ + "name": "Webserver Lokal", + "host": "10.10.18.17", + "protocol": "sftp", + "port": 22, + "username": "administrator", + "password": "EPI2016asta", + "remotePath": "/var/www/html/episupport", + "uploadOnSave": true, + "useTempFile": false, + "openSsh": true, + "ignore": [ + ".vscode", + ".git", + ".git/**", + ".env", + "data/**" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3937c3a --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# EPI-Support Monitor + +Interne Überwachungs- und **Auswertungsplattform** für die Tickets auf +`epi-helpdesk.zammad.com`. Die Anwendung ruft regelmäßig alle sichtbaren Tickets +über die Zammad-API ab, vergleicht sie mit dem letzten Stand und protokolliert +**Änderungen durch die Support-Agenten** (Status, Bearbeiter, Priorität, +Schließung, neue Antworten). Optional gibt es E-Mail-Alerts. + +Das interne Dashboard bietet: + +* **Übersicht** – alle Tickets mit Statusfilter und „ungelesene Änderungen“ +* **Änderungs-Verlauf** – Feed + Timeline pro Ticket (wer/was/wann geändert) +* **Auswertung** – KPIs (Aufkommen, offen/geschlossen, eskaliert), Median/Ø/P90 + von Reaktions- und Lösungszeit, Zeitverlauf pro Monat (SVG-Diagramm) sowie + Verteilungen nach Status, Priorität, Gruppe und **Agent**; Zeitraumfilter + (30/90/180/365 Tage/gesamt) +* **Excel-Export** – komplette Auswertung als `.xlsx` (KPIs, Verteilungen, + Monatswerte, Tickets, Änderungen) +* **Konversation lesen** – vollständiger Nachrichtenverlauf je Ticket + (Absenderrolle, Datum, Text). HTML wird sicher zu Text bereinigt – keine + nachgeladenen Bilder/Tracking, kein eingebetteter Schadcode. +* **Antworten & neue Tickets** – direkt aus der Plattform an EPI; jede + Schreibaktion läuft über eine **Bestätigungsseite** (Schutz vor + versehentlichem Senden). Schreibzugriff nutzt CSRF (Session) bzw. den + `ZAMMAD_TOKEN`. + +> **Hinweis zum Versand:** Der Sende-Pfad (`create_article`/`create_ticket`, +> Artikeltyp `web`) ist gebaut und geprüft, aber bewusst **nicht live getestet**, +> um keine echten Nachrichten an EPI auszulösen. Der erste echte Versand sollte +> kontrolliert erfolgen; sollte EPI den Artikeltyp ablehnen, ggf. auf `note` +> umstellen (in `app/zammad.py`). + +Da auf der Zammad-Instanz „API password access" deaktiviert ist, meldet sich +die App per **Session-Login** an (wie der Browser). Sobald ein persönlicher +**API-Token** verfügbar ist, einfach `ZAMMAD_TOKEN` in der `.env` setzen – dann +wird der Token bevorzugt verwendet. + +## Architektur + +| Komponente | Aufgabe | +|------------|---------| +| `episupport-poller` (systemd) | Hintergrund-Abruf alle `POLL_INTERVAL_SECONDS`, Änderungserkennung, E-Mail | +| `episupport-web` (systemd, gunicorn) | Flask-Dashboard auf `127.0.0.1:8071` | +| Apache `conf-available/episupport.conf` | Reverse-Proxy `srhnweb/episupport` → gunicorn | +| `data/episupport.db` (SQLite, WAL) | Ticket-Snapshots, Änderungs-Log, Abrufprotokoll | + +## Deployment auf SRHNWEB + +```bash +# 1. Dateien auf den Server bringen, Zielordner /var/www/html/episupport +# (z. B. per scp/rsync/git) + +# 2. Setup ausführen +sudo bash /var/www/html/episupport/deploy/setup.sh + +# 3. Zugangsdaten eintragen +sudo nano /var/www/html/episupport/.env # ZAMMAD_USER / ZAMMAD_PASSWORD +sudo systemctl restart episupport-poller episupport-web +``` + +Danach erreichbar unter **`http(s)://srhnweb/episupport`**. + +### Updates einspielen +Neue Dateien nach `/var/www/html/episupport` kopieren, dann erneut +`sudo bash deploy/setup.sh` (installiert Abhängigkeiten neu und startet Dienste). + +## Konfiguration (`.env`) +Siehe `.env.example`. Wichtig: `ZAMMAD_USER`/`ZAMMAD_PASSWORD` **oder** +`ZAMMAD_TOKEN`. E-Mail-Benachrichtigung über `NOTIFY_ENABLED=true` + SMTP-Daten. + +## Lokaler Test (Entwicklung) +```bash +python -m venv venv && venv/bin/pip install -r requirements.txt +cp .env.example .env # ausfüllen +venv/bin/python run_poller.py # einmal laufen lassen -> Basis-Snapshot +venv/bin/python wsgi.py # Dashboard auf http://127.0.0.1:8071/episupport +``` + +## Logs +```bash +journalctl -u episupport-poller -f +journalctl -u episupport-web -f +``` + +## Sicherheit +- `.env` enthält Zugangsdaten → Rechte 640, nicht ins Git committen (`.gitignore`). +- Zugriff aufs Dashboard ggf. in `episupport-apache.conf` per `Require ip` aufs + interne Netz beschränken. +- Für den Dauerbetrieb einen eigenen, einzeln widerrufbaren API-Token statt des + Passworts verwenden. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/analytics.py b/app/analytics.py new file mode 100644 index 0000000..69650d6 --- /dev/null +++ b/app/analytics.py @@ -0,0 +1,152 @@ +"""Auswertungen: KPIs, Zeitverläufe, Verteilungen, Reaktions-/Lösungszeiten. + +Rechnet auf Basis der gespeicherten Tickets (raw_json). Bei ~hunderten bis +wenigen tausend Tickets ist das pro Seitenaufruf problemlos schnell. +""" +import json +import statistics +from collections import Counter, defaultdict +from datetime import datetime, timezone + +from . import db + +OPEN_STATES = ("new", "open", "pending reminder", "pending close") + + +# --------------------------------------------------------------- Zeit-Helfer +def parse_dt(value): + if not value: + return None + try: + s = value.replace("Z", "+00:00") if value.endswith("Z") else value + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, AttributeError): + return None + + +def humanize(minutes): + """Minuten -> lesbare Dauer (z. B. '3,2 Std.', '1,5 Tage').""" + if minutes is None: + return "–" + if minutes < 60: + return f"{round(minutes)} Min." + hours = minutes / 60 + if hours < 48: + return f"{hours:.1f} Std.".replace(".", ",") + return f"{hours / 24:.1f} Tage".replace(".", ",") + + +def _stats(values): + """min/median/avg/p90 aus einer Liste (in Minuten).""" + if not values: + return {"n": 0, "median": None, "avg": None, "p90": None} + s = sorted(values) + p90 = s[min(len(s) - 1, int(round(0.9 * (len(s) - 1))))] + return { + "n": len(s), + "median": statistics.median(s), + "avg": sum(s) / len(s), + "p90": p90, + } + + +# --------------------------------------------------------------- Hauptlogik +def load_tickets(): + with db.get_conn() as conn: + rows = conn.execute("SELECT raw_json FROM tickets").fetchall() + return [json.loads(r["raw_json"]) for r in rows] + + +def compute(period_days=None): + now = datetime.now(timezone.utc) + tickets = load_tickets() + + # Zeitraumfilter auf Erstellungsdatum + if period_days: + cutoff = now.timestamp() - period_days * 86400 + tickets = [ + t for t in tickets + if (d := parse_dt(t.get("created_at"))) and d.timestamp() >= cutoff + ] + + total = len(tickets) + by_state = Counter() + by_priority = Counter() + by_group = Counter() + by_owner = Counter() + by_month = defaultdict(lambda: {"created": 0, "closed": 0}) + + response_min = [] + resolution_min = [] + escalated = 0 + open_ages_days = [] + oldest_open = None + + for t in tickets: + state = t.get("state") or "unbekannt" + by_state[state] += 1 + by_priority[t.get("priority") or "—"] += 1 + by_group[t.get("group") or "—"] += 1 + owner = t.get("owner") + by_owner["(nicht zugewiesen)" if not owner or owner == "-" else owner] += 1 + + created = parse_dt(t.get("created_at")) + first_resp = parse_dt(t.get("first_response_at")) + closed = parse_dt(t.get("last_close_at")) + + if created: + by_month[created.strftime("%Y-%m")]["created"] += 1 + if closed: + by_month[closed.strftime("%Y-%m")]["closed"] += 1 + + if created and first_resp and first_resp >= created: + response_min.append((first_resp - created).total_seconds() / 60) + if created and closed and closed >= created: + resolution_min.append((closed - created).total_seconds() / 60) + + if t.get("escalation_at"): + escalated += 1 + + if state in OPEN_STATES and created: + age = (now - created).total_seconds() / 86400 + open_ages_days.append(age) + if oldest_open is None or age > oldest_open["age"]: + oldest_open = { + "age": age, "number": t.get("number"), + "title": t.get("title"), "id": t.get("id"), + } + + open_count = sum(by_state[s] for s in by_state if s in OPEN_STATES) + closed_count = by_state.get("closed", 0) + + # Monatsreihe lückenlos auffüllen und sortieren + months = sorted(by_month.keys()) + monthly = [ + {"month": m, "created": by_month[m]["created"], "closed": by_month[m]["closed"]} + for m in months + ] + + return { + "total": total, + "open": open_count, + "closed": closed_count, + "escalated": escalated, + "response": _stats(response_min), + "resolution": _stats(resolution_min), + "by_state": by_state.most_common(), + "by_priority": _ordered_priority(by_priority), + "by_group": by_group.most_common(), + "by_owner": by_owner.most_common(), + "monthly": monthly, + "open_age_avg_days": (sum(open_ages_days) / len(open_ages_days)) if open_ages_days else None, + "oldest_open": oldest_open, + "humanize": humanize, + } + + +def _ordered_priority(counter): + order = {"1 low": 0, "2 normal": 1, "3 high": 2} + return sorted(counter.items(), key=lambda kv: order.get(kv[0], 99)) diff --git a/app/charts.py b/app/charts.py new file mode 100644 index 0000000..3cfb2ae --- /dev/null +++ b/app/charts.py @@ -0,0 +1,72 @@ +"""Server-seitige SVG-Diagramme – ohne JavaScript/CDN, voll offline-fähig.""" +from html import escape + +C_CREATED = "#0b6e4f" +C_CLOSED = "#c0392b" +C_GRID = "#e1e7ec" +C_AXIS = "#7b8794" + + +def monthly_chart(monthly, width=760, height=260): + """Liniendiagramm: erstellte vs. geschlossene Tickets pro Monat.""" + if not monthly: + return '

Keine Daten im Zeitraum.

' + + pad_l, pad_r, pad_t, pad_b = 38, 12, 14, 42 + plot_w = width - pad_l - pad_r + plot_h = height - pad_t - pad_b + + n = len(monthly) + max_v = max(max(m["created"], m["closed"]) for m in monthly) or 1 + # auf "schöne" Obergrenze runden + step = max(1, -(-max_v // 4)) # ceil + y_max = step * 4 + + def x(i): + return pad_l + (plot_w * (i / (n - 1)) if n > 1 else plot_w / 2) + + def y(v): + return pad_t + plot_h - (v / y_max) * plot_h + + parts = [f''] + + # horizontale Gitterlinien + Y-Beschriftung + for k in range(5): + v = step * k + yy = y(v) + parts.append(f'') + parts.append(f'{v}') + + # X-Beschriftung (höchstens ~12 Labels) + label_every = max(1, n // 12) + for i, m in enumerate(monthly): + if i % label_every == 0 or i == n - 1: + parts.append(f'{escape(m["month"])}') + + def polyline(key, color): + pts = " ".join(f"{x(i):.1f},{y(m[key]):.1f}" for i, m in enumerate(monthly)) + out = [f''] + for i, m in enumerate(monthly): + out.append(f'') + return "".join(out) + + parts.append(polyline("created", C_CREATED)) + parts.append(polyline("closed", C_CLOSED)) + + # Legende + parts.append(f'' + f'' + f'erstellt' + f'' + f'geschlossen' + f'') + + parts.append("") + return "".join(parts) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c4a834e --- /dev/null +++ b/app/config.py @@ -0,0 +1,44 @@ +"""Zentrale Konfiguration, geladen aus der .env-Datei.""" +import os +from pathlib import Path + +from dotenv import load_dotenv + +# Projektwurzel = ein Verzeichnis über diesem File +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") + +DATA_DIR = BASE_DIR / "data" +DATA_DIR.mkdir(exist_ok=True) +DB_PATH = DATA_DIR / "episupport.db" + + +def _b(value: str, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in ("1", "true", "yes", "on", "ja") + + +# --- Zammad --- +ZAMMAD_URL = os.getenv("ZAMMAD_URL", "https://epi-helpdesk.zammad.com").rstrip("/") +ZAMMAD_TOKEN = os.getenv("ZAMMAD_TOKEN", "").strip() +ZAMMAD_USER = os.getenv("ZAMMAD_USER", "").strip() +ZAMMAD_PASSWORD = os.getenv("ZAMMAD_PASSWORD", "") + +# --- Abruf --- +POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "180")) + +# --- Web --- +URL_PREFIX = os.getenv("EPISUPPORT_URL_PREFIX", "/episupport").rstrip("/") +PORT = int(os.getenv("EPISUPPORT_PORT", "8071")) + +# --- E-Mail --- +NOTIFY_ENABLED = _b(os.getenv("NOTIFY_ENABLED"), False) +SMTP_HOST = os.getenv("SMTP_HOST", "").strip() +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "").strip() +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_STARTTLS = _b(os.getenv("SMTP_STARTTLS"), True) +SMTP_SSL = _b(os.getenv("SMTP_SSL"), False) # True => SMTPS (Port 465) +SMTP_FROM = os.getenv("SMTP_FROM", "").strip() +NOTIFY_TO = [a.strip() for a in os.getenv("NOTIFY_TO", "").split(",") if a.strip()] diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..158f7c1 --- /dev/null +++ b/app/db.py @@ -0,0 +1,89 @@ +"""SQLite-Zugriff: Schema, Verbindung, Hilfsfunktionen. + +Die Datenbank wird vom Poller-Dienst geschrieben und vom Web-Dienst gelesen. +WAL-Modus erlaubt gleichzeitiges Lesen/Schreiben ohne Sperrkonflikte. +""" +import sqlite3 +from contextlib import contextmanager + +from . import config + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY, + number TEXT, + title TEXT, + state TEXT, + state_id INTEGER, + priority TEXT, + priority_id INTEGER, + owner TEXT, + owner_id INTEGER, + "group" TEXT, + customer TEXT, + article_count INTEGER, + zammad_created_at TEXT, + zammad_updated_at TEXT, + last_contact_at TEXT, + last_contact_agent_at TEXT, + last_contact_customer_at TEXT, + close_at TEXT, + last_close_at TEXT, + raw_json TEXT, + first_seen_at TEXT, + last_synced_at TEXT +); + +CREATE TABLE IF NOT EXISTS changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + ticket_number TEXT, + ticket_title TEXT, + field TEXT NOT NULL, + label TEXT, + old_value TEXT, + new_value TEXT, + detail TEXT, + detected_at TEXT NOT NULL, + seen INTEGER NOT NULL DEFAULT 0, + notified INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_changes_detected ON changes(detected_at DESC); +CREATE INDEX IF NOT EXISTS idx_changes_ticket ON changes(ticket_id); +CREATE INDEX IF NOT EXISTS idx_changes_seen ON changes(seen); + +CREATE TABLE IF NOT EXISTS sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT, + finished_at TEXT, + ticket_count INTEGER, + change_count INTEGER, + status TEXT, + error TEXT +); +""" + + +def connect() -> sqlite3.Connection: + conn = sqlite3.connect(config.DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA busy_timeout=10000;") + conn.execute("PRAGMA foreign_keys=ON;") + return conn + + +@contextmanager +def get_conn(): + conn = connect() + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db() -> None: + with get_conn() as conn: + conn.executescript(SCHEMA) diff --git a/app/export.py b/app/export.py new file mode 100644 index 0000000..c1b9fba --- /dev/null +++ b/app/export.py @@ -0,0 +1,112 @@ +"""Excel-Export der Auswertung (mehrere Tabellenblätter).""" +import io +import json + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +from . import analytics, db + +HEAD_FILL = PatternFill("solid", fgColor="0B6E4F") +HEAD_FONT = Font(color="FFFFFF", bold=True) + + +def _style_header(ws, ncols): + for c in range(1, ncols + 1): + cell = ws.cell(row=1, column=c) + cell.fill = HEAD_FILL + cell.font = HEAD_FONT + cell.alignment = Alignment(vertical="center") + ws.freeze_panes = "A2" + + +def _autosize(ws, max_width=60): + for col in ws.columns: + length = max((len(str(c.value)) for c in col if c.value is not None), default=8) + ws.column_dimensions[get_column_letter(col[0].column)].width = min(length + 2, max_width) + + +def build_workbook(period_days=None) -> bytes: + m = analytics.compute(period_days) + h = m["humanize"] + wb = Workbook() + + # --- KPIs --- + ws = wb.active + ws.title = "KPIs" + ws.append(["Kennzahl", "Wert"]) + rows = [ + ("Tickets gesamt", m["total"]), + ("offen", m["open"]), + ("geschlossen", m["closed"]), + ("eskaliert", m["escalated"]), + ("Ø Reaktionszeit", h(m["response"]["avg"])), + ("Median Reaktionszeit", h(m["response"]["median"])), + ("Ø Lösungszeit", h(m["resolution"]["avg"])), + ("Median Lösungszeit", h(m["resolution"]["median"])), + ("Ø Alter offener Tickets (Tage)", + round(m["open_age_avg_days"], 1) if m["open_age_avg_days"] else "–"), + ] + for r in rows: + ws.append(list(r)) + _style_header(ws, 2) + _autosize(ws) + + # --- Verteilungen --- + ws = wb.create_sheet("Verteilungen") + ws.append(["Kategorie", "Wert", "Anzahl"]) + for label, data in (("Status", m["by_state"]), ("Priorität", m["by_priority"]), + ("Gruppe", m["by_group"]), ("Bearbeiter", m["by_owner"])): + for key, cnt in data: + ws.append([label, key, cnt]) + _style_header(ws, 3) + _autosize(ws) + + # --- Monatswerte --- + ws = wb.create_sheet("Monatswerte") + ws.append(["Monat", "erstellt", "geschlossen"]) + for row in m["monthly"]: + ws.append([row["month"], row["created"], row["closed"]]) + _style_header(ws, 3) + _autosize(ws) + + # --- Tickets --- + ws = wb.create_sheet("Tickets") + cols = ["Nummer", "Titel", "Status", "Priorität", "Gruppe", "Bearbeiter", + "Artikel", "erstellt", "1. Reaktion", "geschlossen", "zuletzt geändert"] + ws.append(cols) + with db.get_conn() as conn: + for r in conn.execute("SELECT raw_json FROM tickets ORDER BY zammad_updated_at DESC"): + t = json.loads(r["raw_json"]) + ws.append([ + t.get("number"), t.get("title"), t.get("state"), t.get("priority"), + t.get("group"), t.get("owner"), t.get("article_count"), + _d(t.get("created_at")), _d(t.get("first_response_at")), + _d(t.get("last_close_at")), _d(t.get("updated_at")), + ]) + _style_header(ws, len(cols)) + _autosize(ws) + + # --- Änderungen --- + ws = wb.create_sheet("Änderungen") + ws.append(["Zeitpunkt", "Ticket", "Titel", "Feld", "alt", "neu", "Detail"]) + with db.get_conn() as conn: + for c in conn.execute( + "SELECT detected_at, ticket_number, ticket_title, label, old_value, " + "new_value, detail FROM changes ORDER BY detected_at DESC LIMIT 5000" + ): + ws.append([_d(c["detected_at"]), c["ticket_number"], c["ticket_title"], + c["label"], c["old_value"], c["new_value"], c["detail"]]) + _style_header(ws, 7) + _autosize(ws) + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _d(value): + if not value: + return "" + return str(value).replace("T", " ")[:16] diff --git a/app/notify.py b/app/notify.py new file mode 100644 index 0000000..766d8ab --- /dev/null +++ b/app/notify.py @@ -0,0 +1,76 @@ +"""E-Mail-Benachrichtigung über erkannte Änderungen (optional).""" +import logging +import smtplib +from email.message import EmailMessage + +from . import config + +log = logging.getLogger("episupport.notify") + + +def send_change_mail(changes: list[dict]) -> None: + """Verschickt eine Sammelmail über neue Änderungen. + + `changes` sind dicts mit ticket_number, ticket_title, label, old_value, + new_value, detail. + """ + if not config.NOTIFY_ENABLED or not changes: + return + if not (config.SMTP_HOST and config.NOTIFY_TO and config.SMTP_FROM): + log.warning("E-Mail aktiviert, aber SMTP/Empfänger unvollständig konfiguriert.") + return + + lines = [] + for c in changes: + line = f"#{c['ticket_number']} {c['ticket_title']}\n {c['label']}: {c.get('old_value') or '–'} -> {c.get('new_value') or '–'}" + if c.get("detail"): + line += f"\n {c['detail']}" + lines.append(line) + body = ( + f"Es gibt {len(changes)} neue Änderung(en) an euren EPI-Support-Tickets:\n\n" + + "\n\n".join(lines) + + f"\n\nDashboard: {config.ZAMMAD_URL}\n(Quelle: EPI-Support-Monitor)" + ) + + msg = EmailMessage() + msg["Subject"] = f"[EPI-Support] {len(changes)} Ticket-Änderung(en)" + msg["From"] = config.SMTP_FROM + msg["To"] = ", ".join(config.NOTIFY_TO) + msg.set_content(body) + + try: + _send(msg) + log.info("Benachrichtigungsmail an %s versendet.", config.NOTIFY_TO) + except Exception as e: # noqa: BLE001 + log.error("E-Mail-Versand fehlgeschlagen: %s", e) + + +def _send(msg) -> None: + """Versand über SMTPS (465) oder SMTP+STARTTLS (587/25).""" + if config.SMTP_SSL: + with smtplib.SMTP_SSL(config.SMTP_HOST, config.SMTP_PORT, timeout=30) as s: + if config.SMTP_USER: + s.login(config.SMTP_USER, config.SMTP_PASSWORD) + s.send_message(msg) + else: + with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=30) as s: + s.ehlo() + if config.SMTP_STARTTLS: + s.starttls() + s.ehlo() + if config.SMTP_USER: + s.login(config.SMTP_USER, config.SMTP_PASSWORD) + s.send_message(msg) + + +def send_test_mail() -> None: + """Einmalige Testmail (zum Prüfen der SMTP-Einstellungen).""" + msg = EmailMessage() + msg["Subject"] = "[EPI-Support] Testmail – SMTP funktioniert" + msg["From"] = config.SMTP_FROM + msg["To"] = ", ".join(config.NOTIFY_TO) + msg.set_content( + "Dies ist eine Testnachricht des EPI-Support-Monitors.\n" + "Wenn du diese Mail erhältst, ist der E-Mail-Versand korrekt eingerichtet." + ) + _send(msg) diff --git a/app/poller.py b/app/poller.py new file mode 100644 index 0000000..65d524b --- /dev/null +++ b/app/poller.py @@ -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() diff --git a/app/textutil.py b/app/textutil.py new file mode 100644 index 0000000..23cc6b4 --- /dev/null +++ b/app/textutil.py @@ -0,0 +1,51 @@ +"""HTML aus E-Mail-Artikeln sicher in lesbaren Text umwandeln. + +Bewusst ohne externe Bibliothek und ohne Ausgabe von HTML: So können weder +Schadcode noch nachgeladene Bilder/Tracking-Pixel aus Support-Mails ausgeführt +werden. Block-Tags werden zu Zeilenumbrüchen, der Rest entfernt. +""" +import re +from html.parser import HTMLParser + +_BLOCK = {"p", "div", "br", "li", "tr", "table", "blockquote", "h1", "h2", + "h3", "h4", "h5", "h6", "ul", "ol", "section", "article", "header"} +_DROP = {"script", "style", "head", "title"} + + +class _Extractor(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self._skip = 0 + + def handle_starttag(self, tag, attrs): + if tag in _DROP: + self._skip += 1 + elif tag in _BLOCK: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in _DROP and self._skip: + self._skip -= 1 + elif tag in _BLOCK: + self.parts.append("\n") + + def handle_data(self, data): + if not self._skip: + self.parts.append(data) + + +def clean_html(html: str) -> str: + if not html: + return "" + p = _Extractor() + try: + p.feed(html) + except Exception: # noqa: BLE001 - bei kaputtem HTML lieber Rohtext + return re.sub(r"<[^>]+>", "", html).strip() + text = "".join(p.parts) + # Mehrfache Leerzeichen/Leerzeilen zusammenfassen + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r" *\n *", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() diff --git a/app/web.py b/app/web.py new file mode 100644 index 0000000..8f6f888 --- /dev/null +++ b/app/web.py @@ -0,0 +1,327 @@ +"""Flask-Dashboard für den EPI-Support-Monitor. + +Läuft hinter Apache unter dem Pfad EPISUPPORT_URL_PREFIX (z. B. /episupport). +Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit +Präfix erzeugt. +""" +import logging + +from flask import (Flask, Response, abort, flash, redirect, render_template, + request, url_for) + +from . import analytics, charts, config, db, export, textutil +from .zammad import ZammadClient, ZammadError + +log = logging.getLogger("episupport.web") + +# Lazy initialisierter Zammad-Client für Live-Abrufe (Konversation). +_client = None + + +def _zammad(): + global _client + if _client is None: + _client = ZammadClient() + return _client + + +def _load_conversation(ticket_id): + """Holt die Artikel eines Tickets live und bereitet sie auf. + Gibt (articles, error) zurück.""" + try: + raw = _zammad().ticket_articles(ticket_id) + except ZammadError as e: + return [], str(e) + out = [] + for a in raw: + body = a.get("body") or "" + if (a.get("content_type") or "").startswith("text/html"): + body = textutil.clean_html(body) + atts = [x.get("filename") for x in (a.get("attachments") or []) + if x.get("filename") and x.get("filename") != "message.html"] + out.append({ + "id": a.get("id"), + "sender": a.get("sender"), # Customer / Agent / System + "frm": a.get("from") or "", + "created_at": a.get("created_at"), + "subject": a.get("subject"), + "internal": a.get("internal"), + "body": body, + "attachments": atts, + }) + return out, None + + +class PrefixMiddleware: + """Setzt SCRIPT_NAME, damit url_for hinter dem Apache-Subpfad stimmt.""" + + def __init__(self, app, prefix=""): + self.app = app + self.prefix = prefix + + def __call__(self, environ, start_response): + if self.prefix: + environ["SCRIPT_NAME"] = self.prefix + path = environ.get("PATH_INFO", "") + if path.startswith(self.prefix): + environ["PATH_INFO"] = path[len(self.prefix):] + return self.app(environ, start_response) + + +def create_app() -> Flask: + app = Flask(__name__, template_folder="../templates", static_folder="../static") + app.secret_key = "episupport-local" # nur für Flash-Messages, keine Logins + app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config.URL_PREFIX) + db.init_db() + + # ------------------------------------------------------------- Filter + @app.template_filter("dt") + def _dt(value): + if not value: + return "–" + return str(value).replace("T", " ")[:16] + + @app.context_processor + def _inject(): + return {"zammad_url": config.ZAMMAD_URL} + + # ------------------------------------------------------------- Routen + # erlaubte Sortierspalten (Anzeigename -> DB-Spalte) + SORT_COLS = { + "number": "number", "title": "title", "state": "state", + "priority": "priority_id", "owner": "owner", + "articles": "article_count", "updated": "zammad_updated_at", + "created": "zammad_created_at", + } + + @app.route("/") + def index(): + state = request.args.get("state", "") + sort = request.args.get("sort", "updated") + if sort not in SORT_COLS: + sort = "updated" + direction = "asc" if request.args.get("dir") == "asc" else "desc" + + with db.get_conn() as conn: + params = [] + where = "" + if state == "not_closed": + where = "WHERE state NOT IN ('closed','merged','removed')" + elif state: + where = "WHERE state = ?" + params.append(state) + + # Tickets mit unbestätigten (ungelesenen) Änderungen + unseen_map = { + r["ticket_id"]: r["c"] for r in conn.execute( + "SELECT ticket_id, COUNT(*) c FROM changes WHERE seen=0 " + "GROUP BY ticket_id" + ).fetchall() + } + + col = SORT_COLS[sort] + # Tickets mit offenen Änderungen immer zuerst, dann nach Sortierwahl + order = (f'CASE WHEN id IN (SELECT DISTINCT ticket_id FROM changes ' + f'WHERE seen=0) THEN 0 ELSE 1 END, {col} {direction.upper()}') + tickets = conn.execute( + f"SELECT * FROM tickets {where} ORDER BY {order}", params + ).fetchall() + + states = conn.execute( + "SELECT state, COUNT(*) c FROM tickets GROUP BY state ORDER BY c DESC" + ).fetchall() + stats = { + "total": conn.execute("SELECT COUNT(*) c FROM tickets").fetchone()["c"], + "open": conn.execute( + "SELECT COUNT(*) c FROM tickets WHERE state NOT IN ('closed','merged','removed')" + ).fetchone()["c"], + "unseen": conn.execute( + "SELECT COUNT(*) c FROM changes WHERE seen=0" + ).fetchone()["c"], + } + recent = conn.execute( + "SELECT * FROM changes WHERE seen=0 ORDER BY detected_at DESC LIMIT 8" + ).fetchall() + last_run = conn.execute( + "SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1" + ).fetchone() + return render_template("index.html", tickets=tickets, states=states, + stats=stats, recent=recent, last_run=last_run, + active_state=state, sort=sort, dir=direction, + unseen_map=unseen_map) + + @app.route("/ticket/") + def ticket_detail(ticket_id): + with db.get_conn() as conn: + ticket = conn.execute( + "SELECT * FROM tickets WHERE id=?", (ticket_id,) + ).fetchone() + if ticket is None: + abort(404) + timeline = conn.execute( + "SELECT * FROM changes WHERE ticket_id=? ORDER BY detected_at DESC", + (ticket_id,) + ).fetchall() + conversation, conv_error = _load_conversation(ticket_id) + return render_template("ticket.html", ticket=ticket, timeline=timeline, + conversation=conversation, conv_error=conv_error) + + # ----------------------------------------------------- Antworten (live) + @app.route("/ticket//reply", methods=["POST"]) + def reply_preview(ticket_id): + body = (request.form.get("body") or "").strip() + with db.get_conn() as conn: + ticket = conn.execute("SELECT number, title FROM tickets WHERE id=?", + (ticket_id,)).fetchone() + if not body: + flash("Bitte zuerst einen Text eingeben.") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + return render_template( + "confirm.html", + heading="Antwort an EPI senden?", + intro=f"Diese Antwort wird live an den EPI-Support zu Ticket " + f"#{ticket['number'] if ticket else ticket_id} übermittelt:", + preview=body, + action_url=url_for("reply_send", ticket_id=ticket_id), + cancel_url=url_for("ticket_detail", ticket_id=ticket_id), + fields={"body": body}, + ) + + @app.route("/ticket//reply/send", methods=["POST"]) + def reply_send(ticket_id): + body = (request.form.get("body") or "").strip() + if not body: + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + try: + _zammad().create_article(ticket_id, body) + flash("✓ Antwort wurde an EPI gesendet.") + except ZammadError as e: + flash("✗ Fehler beim Senden: " + str(e)) + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + + # -------------------------------------------------- Neues Ticket (live) + def _known_groups(): + with db.get_conn() as conn: + rows = conn.execute( + 'SELECT DISTINCT "group" g FROM tickets WHERE "group" IS NOT NULL ' + 'ORDER BY g').fetchall() + return [r["g"] for r in rows] or ["EPI::Support"] + + @app.route("/new") + def new_ticket_form(): + return render_template("new_ticket.html", groups=_known_groups()) + + @app.route("/new/preview", methods=["POST"]) + def new_ticket_preview(): + title = (request.form.get("title") or "").strip() + group = (request.form.get("group") or "").strip() + body = (request.form.get("body") or "").strip() + if not (title and group and body): + flash("Bitte Titel, Gruppe und Nachricht ausfüllen.") + return redirect(url_for("new_ticket_form")) + return render_template( + "confirm.html", + heading="Neues Ticket bei EPI anlegen?", + intro=f"Es wird ein neues Ticket in der Gruppe „{group}“ mit dem " + f"Titel „{title}“ live beim EPI-Support angelegt:", + preview=body, + action_url=url_for("new_ticket_send"), + cancel_url=url_for("new_ticket_form"), + fields={"title": title, "group": group, "body": body}, + ) + + @app.route("/new/send", methods=["POST"]) + def new_ticket_send(): + title = (request.form.get("title") or "").strip() + group = (request.form.get("group") or "").strip() + body = (request.form.get("body") or "").strip() + if not (title and group and body): + return redirect(url_for("new_ticket_form")) + try: + res = _zammad().create_ticket(title, group, body) + flash(f"✓ Ticket #{res.get('number')} wurde bei EPI angelegt.") + tid = res.get("id") + if tid: + return redirect(url_for("ticket_detail", ticket_id=tid)) + except ZammadError as e: + flash("✗ Fehler beim Anlegen: " + str(e)) + return redirect(url_for("new_ticket_form")) + + @app.route("/changes") + def changes(): + show = request.args.get("show", "all") + with db.get_conn() as conn: + if show == "unseen": + rows = conn.execute( + "SELECT * FROM changes WHERE seen=0 ORDER BY detected_at DESC LIMIT 300" + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM changes ORDER BY detected_at DESC LIMIT 300" + ).fetchall() + unseen = conn.execute( + "SELECT COUNT(*) c FROM changes WHERE seen=0").fetchone()["c"] + return render_template("changes.html", rows=rows, show=show, unseen=unseen) + + @app.route("/changes/mark-read", methods=["POST"]) + def mark_read(): + with db.get_conn() as conn: + conn.execute("UPDATE changes SET seen=1 WHERE seen=0") + flash("Alle Änderungen als gelesen markiert.") + return redirect(request.referrer or url_for("index")) + + @app.route("/changes//seen", methods=["POST"]) + def mark_one_read(change_id): + with db.get_conn() as conn: + conn.execute("UPDATE changes SET seen=1 WHERE id=?", (change_id,)) + return redirect(request.referrer or url_for("index")) + + PERIODS = [("30", "30 Tage"), ("90", "90 Tage"), ("180", "6 Monate"), + ("365", "12 Monate"), ("all", "gesamt")] + + def _period_days(): + p = request.args.get("period", "365") + if p == "all": + return None, "all" + try: + return int(p), p + except ValueError: + return 365, "365" + + @app.route("/analytics") + def analytics_view(): + days, active = _period_days() + m = analytics.compute(days) + chart_svg = charts.monthly_chart(m["monthly"]) + return render_template("analytics.html", m=m, chart_svg=chart_svg, + periods=PERIODS, active_period=active, h=m["humanize"]) + + @app.route("/export.xlsx") + def export_xlsx(): + days, active = _period_days() + data = export.build_workbook(days) + fname = f"epi-support-auswertung-{active}.xlsx" + return Response( + data, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + @app.route("/health") + def health(): + with db.get_conn() as conn: + last = conn.execute( + "SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1" + ).fetchone() + if last is None: + return {"status": "starting"}, 200 + return { + "status": last["status"], + "last_run": last["finished_at"], + "tickets": last["ticket_count"], + }, 200 + + return app + + +app = create_app() diff --git a/app/zammad.py b/app/zammad.py new file mode 100644 index 0000000..61a1e27 --- /dev/null +++ b/app/zammad.py @@ -0,0 +1,153 @@ +"""Zammad-API-Client. + +Unterstützt zwei Authentifizierungs-Wege: + * Token (Authorization: Token token=...) -> bevorzugt, sobald ein + persönlicher Token vorliegt. + * Session-Login (Benutzer/Passwort) -> Fallback, solange auf der + Instanz "API password access" deaktiviert ist. + +Re-Login passiert automatisch, falls eine Session abläuft (401/403). +""" +import logging + +import requests + +from . import config + +log = logging.getLogger("episupport.zammad") + + +class ZammadError(RuntimeError): + pass + + +class ZammadClient: + def __init__(self): + self.base = config.ZAMMAD_URL + self.session = requests.Session() + self.session.headers.update({"User-Agent": "episupport-monitor/1.0"}) + self._authed = False + + # ------------------------------------------------------------------ auth + def _login_session(self) -> None: + """Login per CSRF + signin, Cookies bleiben in der Session.""" + r = self.session.get(f"{self.base}/api/v1/signshow", timeout=30) + csrf = r.headers.get("CSRF-TOKEN") + if not csrf: + raise ZammadError("Kein CSRF-Token von /signshow erhalten.") + r = self.session.post( + f"{self.base}/api/v1/signin", + json={ + "username": config.ZAMMAD_USER, + "password": config.ZAMMAD_PASSWORD, + }, + headers={"X-CSRF-Token": csrf}, + timeout=30, + ) + if r.status_code != 201 and r.status_code != 200: + raise ZammadError(f"Login fehlgeschlagen (HTTP {r.status_code}): {r.text[:200]}") + self._authed = True + log.info("Zammad-Session aufgebaut.") + + def ensure_auth(self) -> None: + if config.ZAMMAD_TOKEN: + self.session.headers["Authorization"] = f"Token token={config.ZAMMAD_TOKEN}" + self._authed = True + return + if not self._authed: + self._login_session() + + # ------------------------------------------------------------- requests + def _get(self, path: str, params: dict | None = None, _retry: bool = True): + self.ensure_auth() + url = f"{self.base}{path}" + r = self.session.get(url, params=params, timeout=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) + if r.status_code != 200: + raise ZammadError(f"GET {path} -> HTTP {r.status_code}: {r.text[:200]}") + return r.json() + + # ------------------------------------------------------------- endpoints + def me(self) -> dict: + return self._get("/api/v1/users/me") + + def list_tickets(self) -> list[dict]: + """Alle für den Account sichtbaren Tickets, über alle Seiten.""" + out: list[dict] = [] + page = 1 + per_page = 100 + while True: + batch = self._get( + "/api/v1/tickets", + params={"expand": "true", "page": page, "per_page": per_page}, + ) + if not isinstance(batch, list): + raise ZammadError("Unerwartete Antwort bei /tickets (kein Array).") + out.extend(batch) + if len(batch) < per_page: + break + page += 1 + if page > 100: # Sicherheitsanker gegen Endlosschleife + log.warning("Pagination-Limit erreicht.") + 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 [] + + # ----------------------------------------------------------- schreibend + def _csrf(self) -> str | None: + """Frischen CSRF-Token aus der Session holen (für POST per Session).""" + r = self.session.get(f"{self.base}/api/v1/signshow", timeout=30) + return r.headers.get("CSRF-TOKEN") + + def _post(self, path: str, payload: dict, _retry: bool = True): + self.ensure_auth() + headers = {} + if not config.ZAMMAD_TOKEN: + csrf = self._csrf() + if csrf: + headers["X-CSRF-Token"] = csrf + r = self.session.post(f"{self.base}{path}", json=payload, + headers=headers, timeout=60) + if r.status_code in (401, 403) and _retry and not config.ZAMMAD_TOKEN: + log.warning("Auth/CSRF abgelaufen (HTTP %s), erneuter Login.", r.status_code) + self._authed = False + self._login_session() + return self._post(path, payload, _retry=False) + if r.status_code not in (200, 201): + 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: + """Antwort/Nachricht an ein bestehendes Ticket anhängen (an EPI sichtbar).""" + return self._post("/api/v1/ticket_articles", { + "ticket_id": ticket_id, + "body": body, + "content_type": "text/plain", + "type": "web", + "internal": False, + }) + + def create_ticket(self, title: str, group: str, body: str) -> dict: + """Neues Ticket beim EPI-Support anlegen.""" + return self._post("/api/v1/tickets", { + "title": title, + "group": group, + "article": { + "subject": title, + "body": body, + "content_type": "text/plain", + "type": "web", + "internal": False, + }, + }) diff --git a/deploy/episupport-apache.conf b/deploy/episupport-apache.conf new file mode 100644 index 0000000..6dcd6d2 --- /dev/null +++ b/deploy/episupport-apache.conf @@ -0,0 +1,22 @@ +# EPI-Support Monitor – Apache-Einbindung unter /episupport +# +# Diese Datei wird nach /etc/apache2/conf-available/episupport.conf kopiert +# und mit "a2enconf episupport" aktiviert. Sie hängt sich in den bestehenden +# Apache-VirtualHost (srhnweb) ein – ein eigener VHost ist nicht nötig. +# +# Voraussetzung: Module proxy, proxy_http, headers aktiv +# a2enmod proxy proxy_http headers + + + ProxyPreserveHost On + ProxyPass http://127.0.0.1:8071/ + ProxyPassReverse http://127.0.0.1:8071/ + + # Damit die App ihre Links korrekt mit /episupport-Präfix erzeugt + RequestHeader set X-Forwarded-Prefix "/episupport" + RequestHeader set X-Forwarded-Proto "https" env=HTTPS + + # Optional: Zugriff aufs interne Netz beschränken (anpassen/auskommentieren) + # Require ip 192.168.0.0/16 10.0.0.0/8 127.0.0.1 + Require all granted + diff --git a/deploy/episupport-poller.service b/deploy/episupport-poller.service new file mode 100644 index 0000000..c8228d1 --- /dev/null +++ b/deploy/episupport-poller.service @@ -0,0 +1,23 @@ +[Unit] +Description=EPI-Support Monitor – Hintergrund-Abruf (Poller) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/html/episupport +EnvironmentFile=/var/www/html/episupport/.env +ExecStart=/var/www/html/episupport/venv/bin/python run_poller.py +Restart=on-failure +RestartSec=15 + +# Härtung +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/var/www/html/episupport/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/episupport-web.service b/deploy/episupport-web.service new file mode 100644 index 0000000..fbab6ab --- /dev/null +++ b/deploy/episupport-web.service @@ -0,0 +1,28 @@ +[Unit] +Description=EPI-Support Monitor – Web-Dashboard (gunicorn) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/html/episupport +EnvironmentFile=/var/www/html/episupport/.env +ExecStart=/var/www/html/episupport/venv/bin/python -m gunicorn \ + --workers 2 \ + --bind 127.0.0.1:8071 \ + --access-logfile - \ + --error-logfile - \ + wsgi:app +Restart=on-failure +RestartSec=5 + +# Härtung +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/var/www/html/episupport/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100644 index 0000000..898bb58 --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# ============================================================================ +# EPI-Support Monitor – Setup / Deploy auf dem Linux-Webserver (SRHNWEB) +# +# Annahme: die Projektdateien liegen bereits unter /var/www/html/episupport +# (z. B. per git clone, scp oder rsync dorthin kopiert). +# +# Aufruf auf dem Server: +# sudo bash /var/www/html/episupport/deploy/setup.sh +# +# Das Skript ist idempotent – mehrfaches Ausführen ist unproblematisch +# (z. B. nach einem Update der Dateien für einen Neustart der Dienste). +# ============================================================================ +set -euo pipefail + +APP_DIR="/var/www/html/episupport" +SERVICE_USER="www-data" +PY="${PYTHON:-python3}" + +bold() { printf "\033[1m%s\033[0m\n" "$*"; } +info() { printf " • %s\n" "$*"; } + +if [[ $EUID -ne 0 ]]; then + echo "Bitte mit sudo/root ausführen: sudo bash $0" >&2 + exit 1 +fi + +if [[ ! -f "$APP_DIR/wsgi.py" ]]; then + echo "Projektdateien nicht in $APP_DIR gefunden. Erst dorthin kopieren." >&2 + exit 1 +fi + +cd "$APP_DIR" + +# --- 1) System-Pakete (nur falls fehlend) ----------------------------------- +bold "1/6 System-Pakete prüfen" +if ! command -v "$PY" >/dev/null; then + info "Installiere python3 ..." + apt-get update -qq && apt-get install -y python3 python3-venv python3-pip +fi +if ! "$PY" -m venv --help >/dev/null 2>&1; then + info "Installiere python3-venv ..." + apt-get update -qq && apt-get install -y python3-venv +fi + +# --- 2) Virtualenv + Abhängigkeiten ----------------------------------------- +bold "2/6 Python-Umgebung einrichten" +# Ein vorhandenes venv ohne bin/python ist ungültig (z. B. versehentlich von +# Windows mitkopiert -> hat venv/Scripts/ statt venv/bin/). Dann neu anlegen. +if [[ -d venv && ! -x venv/bin/python ]]; then + info "Ungültiges/fremdes venv gefunden – wird neu erstellt" + rm -rf venv +fi +# Erreicht der Server PyPI? Falls nicht (häufig in abgeschotteten Netzen), +# installieren wir die Pakete über apt und geben dem venv via +# --system-site-packages Zugriff darauf. +PYPI_OK=1 +if ! curl -sS --max-time 15 -o /dev/null https://pypi.org/simple/ 2>/dev/null; then + PYPI_OK=0 + info "PyPI nicht erreichbar – nutze System-Pakete (apt) statt pip" +fi + +VENV_ARGS="" +[[ $PYPI_OK -eq 0 ]] && VENV_ARGS="--system-site-packages" + +if [[ ! -x venv/bin/python ]]; then + if ! "$PY" -m venv $VENV_ARGS venv 2>/dev/null; then + info "venv-Erstellung fehlgeschlagen – installiere python3-venv und versuche erneut" + apt-get update -qq && apt-get install -y python3-venv + "$PY" -m venv $VENV_ARGS venv + fi + info "venv erstellt" +fi + +if [[ $PYPI_OK -eq 1 ]]; then + ./venv/bin/python -m pip install --upgrade pip + ./venv/bin/python -m pip install -r requirements.txt +else + apt-get update -qq + apt-get install -y python3-flask python3-requests python3-dotenv \ + python3-openpyxl gunicorn +fi +# Verifizieren, dass alle Importe verfügbar sind +./venv/bin/python -c "import flask, requests, dotenv, openpyxl, gunicorn" \ + && info "Abhängigkeiten verfügbar" \ + || { echo " ! Python-Abhängigkeiten fehlen weiterhin." >&2; exit 1; } + +# --- 3) Konfiguration + Datenverzeichnis ------------------------------------ +bold "3/6 Konfiguration" +mkdir -p data +if [[ ! -f .env ]]; then + cp .env.example .env + info ".env aus Vorlage erstellt – BITTE Zugangsdaten eintragen:" + info " nano $APP_DIR/.env" +else + info ".env vorhanden (unverändert gelassen)" +fi +chown -R "$SERVICE_USER:$SERVICE_USER" "$APP_DIR" +chmod 640 .env +info "Besitzrechte auf $SERVICE_USER gesetzt" + +# --- 4) systemd-Dienste ------------------------------------------------------ +bold "4/6 systemd-Dienste installieren" +cp deploy/episupport-web.service /etc/systemd/system/episupport-web.service +cp deploy/episupport-poller.service /etc/systemd/system/episupport-poller.service +systemctl daemon-reload +systemctl enable --now episupport-poller.service +systemctl enable --now episupport-web.service +info "episupport-web und episupport-poller aktiviert und gestartet" + +# --- 5) Apache-Anbindung ----------------------------------------------------- +bold "5/6 Apache konfigurieren" +a2enmod proxy proxy_http headers >/dev/null +cp deploy/episupport-apache.conf /etc/apache2/conf-available/episupport.conf +a2enconf episupport >/dev/null +if apache2ctl configtest 2>/dev/null; then + systemctl reload apache2 + info "Apache neu geladen – erreichbar unter http(s)://srhnweb/episupport" +else + echo " ! Apache-Konfigtest fehlgeschlagen – bitte prüfen (apache2ctl configtest)." >&2 +fi + +# --- 6) Status --------------------------------------------------------------- +bold "6/6 Status" +systemctl --no-pager --lines=0 status episupport-web episupport-poller || true + +echo +bold "Fertig." +echo " Dashboard: http(s)://srhnweb/episupport" +echo " Logs Web: journalctl -u episupport-web -f" +echo " Logs Poller: journalctl -u episupport-poller -f" +echo +echo " WICHTIG: Falls noch nicht geschehen, Zugangsdaten in .env eintragen und" +echo " danach: sudo systemctl restart episupport-poller episupport-web" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ee72d9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +requests==2.32.3 +python-dotenv==1.0.1 +gunicorn==22.0.0 +openpyxl==3.1.5 diff --git a/run_poller.py b/run_poller.py new file mode 100644 index 0000000..3c78cd5 --- /dev/null +++ b/run_poller.py @@ -0,0 +1,5 @@ +"""Einstiegspunkt für den Poller-Dienst (Hintergrund-Abruf).""" +from app.poller import run_forever + +if __name__ == "__main__": + run_forever() diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..57ed15c --- /dev/null +++ b/static/style.css @@ -0,0 +1,210 @@ +:root { + --bg: #f4f6f8; + --panel: #ffffff; + --ink: #1f2933; + --muted: #7b8794; + --line: #e1e7ec; + --brand: #0b6e4f; + --brand-dark: #084c37; + --accent: #c0392b; + --shadow: 0 1px 3px rgba(0,0,0,.08); +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--ink); + font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Arial, sans-serif; +} +a { color: var(--brand-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +.topbar { + display: flex; align-items: center; gap: 1.5rem; + background: var(--brand); color: #fff; padding: .7rem 1.2rem; +} +.brand { color: #fff; font-weight: 700; font-size: 1.1rem; } +.topbar nav { display: flex; gap: 1.2rem; margin-left: auto; } +.topbar nav a { color: #d8f3e8; } + +main { max-width: 1100px; margin: 1.4rem auto; padding: 0 1rem; } +footer { max-width: 1100px; margin: 2rem auto; padding: 1rem; color: var(--muted); font-size: .82rem; } + +.flash { + max-width: 1100px; margin: 1rem auto 0; padding: .6rem 1rem; + background: #e8f5ee; border: 1px solid #b7e0c9; border-radius: 6px; +} + +.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.4rem; } +.card { + background: var(--panel); border: 1px solid var(--line); border-radius: 10px; + padding: 1rem 1.2rem; box-shadow: var(--shadow); + display: flex; flex-direction: column; gap: .25rem; +} +.card .num { font-size: 1.9rem; font-weight: 700; } +.card .lbl { color: var(--muted); font-size: .82rem; } +.card.alert { border-color: var(--accent); } +.card.alert .num { color: var(--accent); } +.num.ok { color: var(--brand); } .num.err { color: var(--accent); } + +.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; margin-bottom: 1.4rem; } +@media (max-width: 820px) { .grid, .cards { grid-template-columns: 1fr 1fr; } } + +.panel { + background: var(--panel); border: 1px solid var(--line); border-radius: 10px; + box-shadow: var(--shadow); margin-bottom: 1.4rem; overflow: hidden; +} +.panel-head { + display: flex; align-items: center; justify-content: space-between; + padding: .8rem 1.1rem; border-bottom: 1px solid var(--line); background: #fafbfc; +} +.panel-head h2 { margin: 0; font-size: 1rem; } + +.btn-sm { + background: var(--brand); color: #fff; border: 0; border-radius: 6px; + padding: .35rem .7rem; font-size: .8rem; cursor: pointer; +} +.btn-sm:hover { background: var(--brand-dark); } + +.feed, .timeline { list-style: none; margin: 0; padding: 0; } +.feed li, .timeline li { padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); } +.feed li.unseen, .timeline li.unseen { background: #fff8f1; } +.feed time, .timeline time { display: block; color: var(--muted); font-size: .78rem; margin-top: .2rem; } + +/* Kompakte einzeilige Änderungszeilen */ +.feed.compact li { + display: flex; align-items: center; gap: .55rem; padding: .45rem 1.1rem; + font-size: .85rem; white-space: nowrap; overflow: hidden; +} +.feed.compact .ctitle { flex: 1 1 auto; min-width: 60px; overflow: hidden; text-overflow: ellipsis; color: #44525e; } +.feed.compact .cchg { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } +.feed.compact .cnum { flex: 0 0 auto; font-weight: 600; } +.feed.compact time { display: inline; margin: 0; flex: 0 0 auto; white-space: nowrap; } +.feed.compact .tag { flex: 0 0 auto; margin: 0; } +.mark-one { flex: 0 0 auto; margin: 0; } +.mark-one button { + border: 1px solid #c5e8d3; background: #f1faf4; color: var(--brand); + border-radius: 6px; width: 24px; height: 24px; line-height: 1; cursor: pointer; + font-size: .8rem; padding: 0; +} +.mark-one button:hover { background: var(--brand); color: #fff; } +.seen-dot { flex: 0 0 auto; width: 24px; text-align: center; color: #cbd2d8; } +.more { display: inline-block; padding: .6rem 1.1rem; font-size: .85rem; } +.head-tools { display: flex; align-items: center; gap: .8rem; } +.head-tools .chips { padding: 0; display: flex; gap: .35rem; } +@media (max-width: 560px) { + .feed.compact .cchg { display: none; } +} +.chg { font-size: .9rem; margin-top: .2rem; } +.detail { color: var(--muted); font-size: .82rem; margin-top: .15rem; } + +.tag { + display: inline-block; font-size: .72rem; font-weight: 600; + padding: .1rem .5rem; border-radius: 999px; margin-right: .4rem; + background: #eef2f5; color: #44525e; +} +.tag-state { background: #fde8e6; color: #a83227; } +.tag-owner { background: #e6f0fd; color: #1c54a8; } +.tag-article { background: #e8f5ee; color: #0b6e4f; } +.tag-new_ticket { background: #f3e8fd; color: #6b27a8; } +.tag-priority { background: #fff3d6; color: #8a6300; } + +.chips { display: flex; flex-wrap: wrap; gap: .4rem; padding: 1rem 1.1rem; } +.chip { background: #eef2f5; padding: .25rem .6rem; border-radius: 999px; font-size: .82rem; } +.chip.on { background: var(--brand); color: #fff; } + +table.tickets { width: 100%; border-collapse: collapse; font-size: .9rem; } +table.tickets th, table.tickets td { text-align: left; padding: .55rem .8rem; border-bottom: 1px solid var(--line); } +table.tickets th { background: #fafbfc; color: var(--muted); font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; } +table.tickets td.title { max-width: 360px; } +table.tickets th a { color: var(--muted); text-decoration: none; display: inline-block; } +table.tickets th a:hover { color: var(--ink); } +table.tickets th.sorted a { color: var(--brand-dark); } +table.tickets th .arrow { font-size: .65rem; } + +/* Zeilen mit unbestätigten Änderungen hervorheben */ +table.tickets tr.row-unseen { background: #fff8f1; } +table.tickets tr.row-unseen td { border-left: 0; box-shadow: none; } +table.tickets tr.row-unseen td:first-child { box-shadow: inset 3px 0 0 var(--accent); } +.badge { + display: inline-block; min-width: 18px; padding: 0 5px; margin-right: .35rem; + background: var(--accent); color: #fff; border-radius: 999px; + font-size: .72rem; font-weight: 700; text-align: center; line-height: 18px; +} + +.state { display: inline-block; padding: .1rem .5rem; border-radius: 6px; font-size: .78rem; background: #eef2f5; } +.state-closed { background: #e6eaed; color: #66727c; } +.state-open { background: #fde8e6; color: #a83227; } +.state-new { background: #fff3d6; color: #8a6300; } +[class^="state-"].state-pending\ reminder, .state-pending { background: #e6f0fd; color: #1c54a8; } + +.meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: .4rem 2rem; padding: 1rem 1.1rem; margin: 0; } +.meta div { display: flex; justify-content: space-between; border-bottom: 1px dotted var(--line); padding: .25rem 0; } +.meta dt { color: var(--muted); margin: 0; } +.meta dd { margin: 0; font-weight: 600; } + +.muted { color: var(--muted); padding: 1rem 1.1rem; } +.back { font-size: .9rem; } + +/* --- Auswertung --- */ +.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; } +.toolbar .chips { padding: 0; } +.cards-6 { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 820px) { .cards-6 { grid-template-columns: repeat(2, 1fr); } } +.cards-6 .num { font-size: 1.4rem; } + +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 820px) { .grid-2 { grid-template-columns: 1fr; } } + +.chart-wrap { padding: 1rem; overflow-x: auto; } +.chart { width: 100%; height: auto; } + +.barlist { padding: .8rem 1.1rem; display: flex; flex-direction: column; gap: .5rem; } +.barrow { display: grid; grid-template-columns: 150px 1fr 44px; align-items: center; gap: .6rem; font-size: .85rem; } +.barlabel { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #44525e; } +.bartrack { background: #eef2f5; border-radius: 5px; height: 16px; overflow: hidden; } +.barfill { display: block; height: 100%; background: var(--brand); border-radius: 5px; min-width: 2px; } +.barval { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); } +@media (max-width: 560px) { .barrow { grid-template-columns: 110px 1fr 36px; } } + +table.kv { width: 100%; border-collapse: collapse; font-size: .88rem; } +table.kv th, table.kv td { text-align: left; padding: .45rem .8rem; border-bottom: 1px solid var(--line); } +table.kv th { color: var(--muted); font-weight: 600; font-size: .75rem; text-transform: uppercase; } +table.kv td:first-child { font-weight: 600; } +.hint { padding: .6rem 1.1rem 1rem; font-size: .85rem; color: #44525e; } + +/* --- Konversation --- */ +.thread { display: flex; flex-direction: column; gap: .8rem; padding: 1rem 1.1rem; } +.msg { border: 1px solid var(--line); border-radius: 10px; padding: .7rem .9rem; background: #fff; } +.msg-customer { background: #f0f7ff; border-color: #cfe2fb; margin-left: 2rem; } +.msg-agent { background: #f1faf4; border-color: #c5e8d3; margin-right: 2rem; } +.msg-system { background: #f7f8fa; } +.msg-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; font-size: .82rem; color: var(--muted); } +.who { color: #44525e; } +.role { display: inline-block; font-size: .7rem; font-weight: 700; padding: .05rem .45rem; border-radius: 999px; margin-right: .4rem; } +.role-customer { background: #1c54a8; color: #fff; } +.role-agent { background: var(--brand); color: #fff; } +.role-system { background: #aab4bd; color: #fff; } +.msg-subj { font-weight: 600; margin: .35rem 0 .15rem; font-size: .9rem; } +.msg-body { white-space: pre-wrap; word-wrap: break-word; font-size: .9rem; line-height: 1.45; } +.msg-att { margin-top: .5rem; font-size: .8rem; color: var(--muted); } + +/* --- Formulare / Antworten --- */ +.reply-form { padding: .8rem 1.1rem 1.1rem; border-top: 1px solid var(--line); } +.form { padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .9rem; } +.form label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; font-weight: 600; color: #44525e; } +.form input, .form select, textarea { + font: inherit; padding: .55rem .65rem; border: 1px solid var(--line); + border-radius: 7px; background: #fff; width: 100%; +} +textarea { resize: vertical; } +.reply-form textarea { width: 100%; } +.form-actions { display: flex; align-items: center; justify-content: flex-end; gap: .8rem; margin-top: .6rem; } +.hint-inline { color: var(--muted); font-size: .8rem; margin-right: auto; } + +.confirm-body { padding: 1rem 1.1rem 1.2rem; } +.warn { background: #fff3d6; border: 1px solid #f0d48a; color: #8a6300; padding: .6rem .8rem; border-radius: 7px; font-size: .9rem; } +.preview-box { white-space: pre-wrap; background: #f7f8fa; border: 1px solid var(--line); border-radius: 8px; padding: .8rem; margin: .8rem 0; font-size: .9rem; line-height: 1.45; } +.confirm-actions { display: flex; justify-content: flex-end; gap: .8rem; align-items: center; } +.btn-danger { background: var(--accent); color: #fff; border: 0; border-radius: 7px; padding: .55rem 1rem; font-size: .9rem; font-weight: 600; cursor: pointer; } +.btn-danger:hover { background: #a13325; } +.btn-danger:disabled { opacity: .6; cursor: default; } +.btn-ghost { padding: .55rem 1rem; border-radius: 7px; border: 1px solid var(--line); color: #44525e; font-size: .9rem; } diff --git a/templates/_macros.html b/templates/_macros.html new file mode 100644 index 0000000..dffb218 --- /dev/null +++ b/templates/_macros.html @@ -0,0 +1,17 @@ +{% macro change_row(c) %} +
  • + {{ c.label }} + #{{ c.ticket_number }} + {{ c.ticket_title }} + {{ c.old_value or '–' }} → {{ c.new_value or '–' }} + + {% if not c.seen %} +
    + +
    + {% else %} + · + {% endif %} +
  • +{% endmacro %} diff --git a/templates/analytics.html b/templates/analytics.html new file mode 100644 index 0000000..3ee717a --- /dev/null +++ b/templates/analytics.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Auswertung · EPI-Support{% endblock %} +{% block content %} + +{% macro bars(items) %} + {% set top = (items|map(attribute=1)|max) if items else 1 %} +
    + {% for label, cnt in items %} +
    + {{ label }} + + {{ cnt }} +
    + {% else %} +

    Keine Daten.

    + {% endfor %} +
    +{% endmacro %} + +
    +
    + {% for val, label in periods %} + {{ label }} + {% endfor %} +
    + ⬇ Excel-Export +
    + +
    +
    {{ m.total }}Tickets im Zeitraum
    +
    {{ m.open }}offen
    +
    {{ m.closed }}geschlossen
    +
    {{ m.escalated }}eskaliert
    +
    {{ h(m.response.median) }}Median Reaktionszeit
    +
    {{ h(m.resolution.median) }}Median Lösungszeit
    +
    + +
    +

    Ticket-Aufkommen pro Monat

    +
    {{ chart_svg|safe }}
    +
    + +
    +
    +

    Reaktions- & Lösungszeiten

    + + + + + + + + +
    MedianØP90n
    Reaktionszeit{{ h(m.response.median) }}{{ h(m.response.avg) }}{{ h(m.response.p90) }}{{ m.response.n }}
    Lösungszeit{{ h(m.resolution.median) }}{{ h(m.resolution.avg) }}{{ h(m.resolution.p90) }}{{ m.resolution.n }}
    + {% if m.oldest_open %} +

    Ältestes offenes Ticket: + #{{ m.oldest_open.number }} + {{ m.oldest_open.title }} – seit {{ m.oldest_open.age|round(0)|int }} Tagen + {% if m.open_age_avg_days %}(Ø offen: {{ m.open_age_avg_days|round(1) }} Tage){% endif %} +

    + {% endif %} +
    + +
    +

    Nach Priorität

    + {{ bars(m.by_priority) }} +
    +
    + +
    +
    +

    Nach Status

    + {{ bars(m.by_state) }} +
    +
    +

    Nach Gruppe

    + {{ bars(m.by_group) }} +
    +
    + +
    +

    Nach Bearbeiter (Agent)

    + {{ bars(m.by_owner) }} +
    + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..104efac --- /dev/null +++ b/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}EPI-Support{% endblock %} + + + + +
    + EPI Support Monitor + +
    + + {% with messages = get_flashed_messages() %} + {% if messages %} +
    {{ messages|join(' ') }}
    + {% endif %} + {% endwith %} + +
    + {% block content %}{% endblock %} +
    + +
    + EPI-Support-Monitor · liest {{ zammad_url }} · Seite aktualisiert sich automatisch +
    + + diff --git a/templates/changes.html b/templates/changes.html new file mode 100644 index 0000000..c8dac15 --- /dev/null +++ b/templates/changes.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% from "_macros.html" import change_row %} +{% block title %}Änderungen · EPI-Support{% endblock %} +{% block content %} + +
    +
    +

    Änderungen

    +
    + + alle + nur ungelesene ({{ unseen }}) + + {% if unseen %} +
    + +
    + {% endif %} +
    +
    + {% if rows %} +
      + {% for c in rows %}{{ change_row(c) }}{% endfor %} +
    + {% else %} +

    {{ 'Keine ungelesenen Änderungen.' if show == 'unseen' else 'Noch keine Änderungen erfasst.' }}

    + {% endif %} +
    +{% endblock %} diff --git a/templates/confirm.html b/templates/confirm.html new file mode 100644 index 0000000..8244853 --- /dev/null +++ b/templates/confirm.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Bestätigen · EPI-Support{% endblock %} +{% block content %} + +
    +

    {{ heading }}

    +
    +

    ⚠ {{ intro }}

    +
    {{ preview }}
    + +
    + {% for name, value in fields.items() %} + + {% endfor %} + Abbrechen + +
    +
    +
    +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..8498db6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% from "_macros.html" import change_row %} +{% block title %}Übersicht · EPI-Support{% endblock %} +{% block content %} + +
    +
    {{ stats.total }}Tickets gesamt
    +
    {{ stats.open }}offen
    +
    + {{ stats.unseen }}ungelesene Änderungen +
    +
    + {% if last_run %} + + {{ '✓' if last_run.status == 'ok' else '!' }} + + letzter Abruf
    {{ last_run.finished_at|dt }}
    + {% else %} + startet + {% endif %} +
    +
    + +
    +
    +
    +

    Ungelesene Änderungen{% if stats.unseen %} ({{ stats.unseen }}){% endif %}

    + {% if stats.unseen %} +
    + +
    + {% endif %} +
    + {% if recent %} +
      + {% for c in recent %}{{ change_row(c) }}{% endfor %} +
    + {% if stats.unseen > recent|length %} + + + {{ stats.unseen - recent|length }} weitere ungelesene anzeigen → + {% endif %} + {% else %} +

    Alles gelesen – keine offenen Änderungen. 🎉

    + {% endif %} +
    + +
    +

    Status-Filter

    +
    + alle + nicht geschlossen + {% for s in states %} + {{ s.state }} ({{ s.c }}) + {% endfor %} +
    +
    +
    + +{% macro th(col, label) %} + {% set nextdir = 'asc' if (sort == col and dir == 'desc') else 'desc' %} + + + {{ label }}{% if sort == col %} {{ '▼' if dir == 'desc' else '▲' }}{% endif %} + + +{% endmacro %} + +
    +
    +

    Tickets{% if active_state == 'not_closed' %} – nicht geschlossen{% elif active_state %} – {{ active_state }}{% endif %}

    +
    + + + + {{ th('number', '#') }}{{ th('title', 'Titel') }}{{ th('state', 'Status') }} + {{ th('priority', 'Priorität') }}{{ th('owner', 'Bearbeiter') }} + {{ th('articles', 'Artikel') }}{{ th('updated', 'zuletzt geändert') }} + + + + {% for t in tickets %} + {% set n = unseen_map.get(t.id) %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    + {% if n %}{{ n }}{% endif %} + {{ t.number }} + {{ t.title }}{{ t.state }}{{ t.priority }}{{ t.owner if t.owner and t.owner != '-' else '—' }}{{ t.article_count }}
    Keine Tickets.
    +
    +{% endblock %} diff --git a/templates/new_ticket.html b/templates/new_ticket.html new file mode 100644 index 0000000..1d97a58 --- /dev/null +++ b/templates/new_ticket.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Neues Ticket · EPI-Support{% endblock %} +{% block content %} + +

    ← zurück zur Übersicht

    + +
    +

    Neues Ticket beim EPI-Support anlegen

    +
    + + + +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/ticket.html b/templates/ticket.html new file mode 100644 index 0000000..0da71e5 --- /dev/null +++ b/templates/ticket.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}#{{ ticket.number }} · EPI-Support{% endblock %} +{% block content %} + +

    ← zurück zur Übersicht

    + +
    +
    +

    #{{ ticket.number }} – {{ ticket.title }}

    + in Zammad öffnen ↗ +
    +
    +
    Status
    {{ ticket.state }}
    +
    Priorität
    {{ ticket.priority }}
    +
    Bearbeiter
    {{ ticket.owner if ticket.owner and ticket.owner != '-' else '—' }}
    +
    Gruppe
    {{ ticket.group }}
    +
    Artikel
    {{ ticket.article_count }}
    +
    erstellt
    {{ ticket.zammad_created_at|dt }}
    +
    zuletzt geändert
    {{ ticket.zammad_updated_at|dt }}
    +
    geschlossen
    {{ ticket.last_close_at|dt }}
    +
    +
    + +
    +
    +

    Konversation{% if conversation %} ({{ conversation|length }}){% endif %}

    +
    + {% if conv_error %} +

    Konversation konnte nicht geladen werden: {{ conv_error }}

    + {% elif conversation %} +
    + {% for a in conversation %} +
    +
    + + {{ a.sender or 'System' }} + {{ a.frm }} + + +
    + {% if a.subject %}
    {{ a.subject }}
    {% endif %} +
    {{ a.body }}
    + {% if a.attachments %} +
    📎 {{ a.attachments|join(', ') }}
    + {% endif %} +
    + {% endfor %} +
    + {% else %} +

    Keine Nachrichten vorhanden.

    + {% endif %} + + {% if ticket.state != 'closed' %} +
    + +
    + Geht nach Bestätigung live an EPI. + +
    +
    + {% else %} +

    Ticket ist geschlossen – zum Antworten ggf. ein neues Ticket anlegen.

    + {% endif %} +
    + +
    +

    Verlauf (von uns erfasst)

    + {% if timeline %} +
      + {% for c in timeline %} +
    • + {{ c.label }} +
      {{ c.old_value or '–' }} → {{ c.new_value or '–' }} + {% if c.detail %}
      {{ c.detail }}
      {% endif %} +
      + +
    • + {% endfor %} +
    + {% else %} +

    Seit Beobachtungsbeginn keine Änderung erfasst.

    + {% endif %} +
    +{% endblock %} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..3464bae --- /dev/null +++ b/wsgi.py @@ -0,0 +1,6 @@ +"""Gunicorn-Einstiegspunkt für den Web-Dienst.""" +from app.web import app # noqa: F401 + +if __name__ == "__main__": + from app import config + app.run(host="127.0.0.1", port=config.PORT, debug=True)