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