first commit

This commit is contained in:
2026-06-29 09:52:02 +02:00
commit da2f7c3c86
31 changed files with 2306 additions and 0 deletions
View File
+152
View File
@@ -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))
+72
View File
@@ -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 '<p class="muted">Keine Daten im Zeitraum.</p>'
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'<svg viewBox="0 0 {width} {height}" class="chart" role="img" '
f'aria-label="Tickets pro Monat" preserveAspectRatio="xMidYMid meet">']
# horizontale Gitterlinien + Y-Beschriftung
for k in range(5):
v = step * k
yy = y(v)
parts.append(f'<line x1="{pad_l}" y1="{yy:.1f}" x2="{width - pad_r}" '
f'y2="{yy:.1f}" stroke="{C_GRID}" stroke-width="1"/>')
parts.append(f'<text x="{pad_l - 6}" y="{yy + 3:.1f}" text-anchor="end" '
f'font-size="10" fill="{C_AXIS}">{v}</text>')
# 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'<text x="{x(i):.1f}" y="{height - pad_b + 16}" '
f'text-anchor="middle" font-size="9" fill="{C_AXIS}" '
f'transform="rotate(0)">{escape(m["month"])}</text>')
def polyline(key, color):
pts = " ".join(f"{x(i):.1f},{y(m[key]):.1f}" for i, m in enumerate(monthly))
out = [f'<polyline points="{pts}" fill="none" stroke="{color}" '
f'stroke-width="2" stroke-linejoin="round"/>']
for i, m in enumerate(monthly):
out.append(f'<circle cx="{x(i):.1f}" cy="{y(m[key]):.1f}" r="2.5" '
f'fill="{color}"/>')
return "".join(out)
parts.append(polyline("created", C_CREATED))
parts.append(polyline("closed", C_CLOSED))
# Legende
parts.append(f'<g font-size="11">'
f'<rect x="{pad_l}" y="{height - 14}" width="10" height="10" fill="{C_CREATED}"/>'
f'<text x="{pad_l + 14}" y="{height - 5}" fill="{C_AXIS}">erstellt</text>'
f'<rect x="{pad_l + 80}" y="{height - 14}" width="10" height="10" fill="{C_CLOSED}"/>'
f'<text x="{pad_l + 94}" y="{height - 5}" fill="{C_AXIS}">geschlossen</text>'
f'</g>')
parts.append("</svg>")
return "".join(parts)
+44
View File
@@ -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()]
+89
View File
@@ -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)
+112
View File
@@ -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]
+76
View File
@@ -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)
+230
View File
@@ -0,0 +1,230 @@
"""Hintergrund-Abruf: Tickets holen, mit letztem Stand vergleichen,
Änderungen protokollieren und ggf. benachrichtigen."""
import json
import logging
import time
from datetime import datetime, timezone
from . import config, db, notify
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.poller")
# Felder, deren Änderung wir festhalten: db-Spalte -> Anzeigename
TRACKED_FIELDS = {
"state": "Status",
"priority": "Priorität",
"owner": "Bearbeiter",
"group": "Gruppe",
"title": "Titel",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _map_ticket(t: dict) -> dict:
"""Zammad-Ticket -> unsere Spalten."""
return {
"id": t["id"],
"number": t.get("number"),
"title": t.get("title"),
"state": t.get("state"),
"state_id": t.get("state_id"),
"priority": t.get("priority"),
"priority_id": t.get("priority_id"),
"owner": t.get("owner"),
"owner_id": t.get("owner_id"),
"group": t.get("group"),
"customer": t.get("customer"),
"article_count": t.get("article_count"),
"zammad_created_at": t.get("created_at"),
"zammad_updated_at": t.get("updated_at"),
"last_contact_at": t.get("last_contact_at"),
"last_contact_agent_at": t.get("last_contact_agent_at"),
"last_contact_customer_at": t.get("last_contact_customer_at"),
"close_at": t.get("close_at"),
"last_close_at": t.get("last_close_at"),
"raw_json": json.dumps(t, ensure_ascii=False),
}
_COLS = [
"id", "number", "title", "state", "state_id", "priority", "priority_id",
"owner", "owner_id", "group", "customer", "article_count",
"zammad_created_at", "zammad_updated_at", "last_contact_at",
"last_contact_agent_at", "last_contact_customer_at", "close_at",
"last_close_at", "raw_json",
]
def _upsert(conn, row: dict, is_new: bool) -> None:
now = _now()
cols = list(_COLS)
quoted = [f'"{c}"' if c == "group" else c for c in cols]
placeholders = ", ".join(["?"] * len(cols))
values = [row[c] for c in cols]
if is_new:
conn.execute(
f"INSERT INTO tickets ({', '.join(quoted)}, first_seen_at, last_synced_at) "
f"VALUES ({placeholders}, ?, ?)",
values + [now, now],
)
else:
assignments = ", ".join(f"{q}=?" for q in quoted)
conn.execute(
f"UPDATE tickets SET {assignments}, last_synced_at=? WHERE id=?",
values + [now, row["id"]],
)
def _record_change(conn, ticket, field, label, old, new, detail=None) -> dict:
now = _now()
conn.execute(
"INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, "
"label, old_value, new_value, detail, detected_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(ticket["id"], ticket.get("number"), ticket.get("title"), field,
label, old, new, detail, now),
)
return {
"ticket_number": ticket.get("number"),
"ticket_title": ticket.get("title"),
"field": field, "label": label,
"old_value": old, "new_value": new, "detail": detail,
}
def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
"""Vergleicht ein frisches Ticket mit dem gespeicherten Stand."""
found: list[dict] = []
for field, label in TRACKED_FIELDS.items():
old_val = old_row[field]
new_val = new_t.get(field)
if (old_val or "") != (new_val or ""):
found.append(_record_change(conn, new_t, field, label, old_val, new_val))
# Neue Artikel/Antworten (article_count gestiegen)
old_count = old_row["article_count"] or 0
new_count = new_t.get("article_count") or 0
if new_count > old_count:
detail = None
articles = client.ticket_articles(new_t["id"])
if articles:
last = articles[-1]
sender = last.get("sender") # Agent / Customer / System
frm = last.get("from") or last.get("created_by") or ""
subject = (last.get("subject") or "").strip()
body = (last.get("body") or "")
preview = body.replace("\n", " ").strip()
if len(preview) > 200:
preview = preview[:200] + ""
detail = f"{sender or 'Antwort'} von {frm}"
if subject:
detail += f" {subject}"
if preview:
detail += f": {preview}"
found.append(_record_change(
conn, new_t, "article", "Neue Antwort/Notiz",
str(old_count), str(new_count), detail))
return found
def poll_once(client: ZammadClient) -> int:
"""Ein Abruf-Durchlauf. Gibt Anzahl erkannter Änderungen zurück."""
started = _now()
status = "ok"
error = None
all_changes: list[dict] = []
ticket_count = 0
try:
tickets = client.list_tickets()
ticket_count = len(tickets)
with db.get_conn() as conn:
for t in tickets:
row = _map_ticket(t)
existing = conn.execute(
"SELECT * FROM tickets WHERE id=?", (t["id"],)
).fetchone()
if existing is None:
_upsert(conn, row, is_new=True)
# Neues Ticket nur dann als "Änderung" melden, wenn die DB
# nicht gerade frisch initialisiert wird (siehe unten).
all_changes.append(_record_change(
conn, t, "new_ticket", "Neues Ticket", None,
f"#{t.get('number')}", None))
else:
all_changes.extend(_detect_changes(conn, client, t, existing))
_upsert(conn, row, is_new=False)
except ZammadError as e:
status = "error"
error = str(e)
log.error("Abruf fehlgeschlagen: %s", e)
except Exception as e: # noqa: BLE001
status = "error"
error = repr(e)
log.exception("Unerwarteter Fehler beim Abruf")
finished = _now()
with db.get_conn() as conn:
conn.execute(
"INSERT INTO sync_runs (started_at, finished_at, ticket_count, "
"change_count, status, error) VALUES (?,?,?,?,?,?)",
(started, finished, ticket_count, len(all_changes), status, error),
)
if all_changes:
log.info("%d Änderung(en) erkannt.", len(all_changes))
try:
notify.send_change_mail(all_changes)
with db.get_conn() as conn:
conn.execute("UPDATE changes SET notified=1 WHERE notified=0")
except Exception: # noqa: BLE001
log.exception("Benachrichtigung fehlgeschlagen")
return len(all_changes)
def _is_first_run() -> bool:
with db.get_conn() as conn:
n = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone()["c"]
return n == 0
def run_forever() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
db.init_db()
client = ZammadClient()
first = _is_first_run()
if first:
log.info("Erster Lauf: lege Basis-Snapshot an (ohne Änderungsmeldungen).")
# Beim allerersten Lauf nur Snapshot speichern, sonst würden alle
# bestehenden Tickets als "neu" gemeldet.
try:
tickets = client.list_tickets()
with db.get_conn() as conn:
for t in tickets:
_upsert(conn, _map_ticket(t), is_new=True)
conn.execute("DELETE FROM changes") # Basislauf erzeugt keine Meldungen
log.info("Basis-Snapshot mit %d Tickets gespeichert.", len(tickets))
except Exception: # noqa: BLE001
log.exception("Basis-Snapshot fehlgeschlagen, versuche es im Intervall erneut.")
log.info("Poller läuft, Intervall %ds.", config.POLL_INTERVAL_SECONDS)
while True:
try:
poll_once(client)
except Exception: # noqa: BLE001
log.exception("poll_once-Schleife fing Ausnahme ab")
time.sleep(config.POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
run_forever()
+51
View File
@@ -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()
+327
View File
@@ -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/<int:ticket_id>")
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/<int:ticket_id>/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/<int:ticket_id>/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/<int:change_id>/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()
+153
View File
@@ -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,
},
})