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
+43
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.env
venv/
data/
__pycache__/
*.pyc
.claude/
+19
View File
@@ -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/**"
]
}
+90
View File
@@ -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.
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,
},
})
+22
View File
@@ -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
<Location /episupport>
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
</Location>
+23
View File
@@ -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
+28
View File
@@ -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
+134
View File
@@ -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"
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""Einstiegspunkt für den Poller-Dienst (Hintergrund-Abruf)."""
from app.poller import run_forever
if __name__ == "__main__":
run_forever()
+210
View File
@@ -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; }
+17
View File
@@ -0,0 +1,17 @@
{% macro change_row(c) %}
<li class="{{ 'unseen' if not c.seen else '' }}"
{% if c.detail %}title="{{ c.detail }}"{% endif %}>
<span class="tag tag-{{ c.field }}">{{ c.label }}</span>
<a class="cnum" href="{{ url_for('ticket_detail', ticket_id=c.ticket_id) }}">#{{ c.ticket_number }}</a>
<span class="ctitle">{{ c.ticket_title }}</span>
<span class="cchg">{{ c.old_value or '' }} → <b>{{ c.new_value or '' }}</b></span>
<time>{{ c.detected_at|dt }}</time>
{% if not c.seen %}
<form method="post" action="{{ url_for('mark_one_read', change_id=c.id) }}" class="mark-one">
<button type="submit" title="Als gelesen markieren"></button>
</form>
{% else %}
<span class="seen-dot" title="gelesen">·</span>
{% endif %}
</li>
{% endmacro %}
+87
View File
@@ -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 %}
<div class="barlist">
{% for label, cnt in items %}
<div class="barrow">
<span class="barlabel" title="{{ label }}">{{ label }}</span>
<span class="bartrack"><span class="barfill" style="width: {{ (cnt / top * 100) if top else 0 }}%"></span></span>
<span class="barval">{{ cnt }}</span>
</div>
{% else %}
<p class="muted">Keine Daten.</p>
{% endfor %}
</div>
{% endmacro %}
<div class="toolbar">
<div class="chips">
{% for val, label in periods %}
<a class="chip {{ 'on' if active_period == val }}"
href="{{ url_for('analytics_view', period=val) }}">{{ label }}</a>
{% endfor %}
</div>
<a class="btn-sm" href="{{ url_for('export_xlsx', period=active_period) }}">⬇ Excel-Export</a>
</div>
<section class="cards cards-6">
<div class="card"><span class="num">{{ m.total }}</span><span class="lbl">Tickets im Zeitraum</span></div>
<div class="card"><span class="num">{{ m.open }}</span><span class="lbl">offen</span></div>
<div class="card"><span class="num">{{ m.closed }}</span><span class="lbl">geschlossen</span></div>
<div class="card {{ 'alert' if m.escalated }}"><span class="num">{{ m.escalated }}</span><span class="lbl">eskaliert</span></div>
<div class="card"><span class="num">{{ h(m.response.median) }}</span><span class="lbl">Median Reaktionszeit</span></div>
<div class="card"><span class="num">{{ h(m.resolution.median) }}</span><span class="lbl">Median Lösungszeit</span></div>
</section>
<section class="panel">
<div class="panel-head"><h2>Ticket-Aufkommen pro Monat</h2></div>
<div class="chart-wrap">{{ chart_svg|safe }}</div>
</section>
<div class="grid-2">
<section class="panel">
<div class="panel-head"><h2>Reaktions- & Lösungszeiten</h2></div>
<table class="kv">
<tr><th></th><th>Median</th><th>Ø</th><th>P90</th><th>n</th></tr>
<tr><td>Reaktionszeit</td>
<td>{{ h(m.response.median) }}</td><td>{{ h(m.response.avg) }}</td>
<td>{{ h(m.response.p90) }}</td><td>{{ m.response.n }}</td></tr>
<tr><td>Lösungszeit</td>
<td>{{ h(m.resolution.median) }}</td><td>{{ h(m.resolution.avg) }}</td>
<td>{{ h(m.resolution.p90) }}</td><td>{{ m.resolution.n }}</td></tr>
</table>
{% if m.oldest_open %}
<p class="hint">Ältestes offenes Ticket:
<a href="{{ url_for('ticket_detail', ticket_id=m.oldest_open.id) }}">#{{ m.oldest_open.number }}</a>
{{ m.oldest_open.title }} seit <b>{{ m.oldest_open.age|round(0)|int }} Tagen</b>
{% if m.open_age_avg_days %}(Ø offen: {{ m.open_age_avg_days|round(1) }} Tage){% endif %}
</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head"><h2>Nach Priorität</h2></div>
{{ bars(m.by_priority) }}
</section>
</div>
<div class="grid-2">
<section class="panel">
<div class="panel-head"><h2>Nach Status</h2></div>
{{ bars(m.by_state) }}
</section>
<section class="panel">
<div class="panel-head"><h2>Nach Gruppe</h2></div>
{{ bars(m.by_group) }}
</section>
</div>
<section class="panel">
<div class="panel-head"><h2>Nach Bearbeiter (Agent)</h2></div>
{{ bars(m.by_owner) }}
</section>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}EPI-Support{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta http-equiv="refresh" content="120">
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}">EPI&nbsp;Support&nbsp;Monitor</a>
<nav>
<a href="{{ url_for('index') }}">Übersicht</a>
<a href="{{ url_for('analytics_view') }}">Auswertung</a>
<a href="{{ url_for('changes') }}">Änderungen</a>
<a href="{{ url_for('new_ticket_form') }}">+ Neues Ticket</a>
<a href="{{ zammad_url }}" target="_blank" rel="noopener">Zammad&nbsp;</a>
</nav>
</header>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash">{{ messages|join(' ') }}</div>
{% endif %}
{% endwith %}
<main>
{% block content %}{% endblock %}
</main>
<footer>
EPI-Support-Monitor · liest <code>{{ zammad_url }}</code> · Seite aktualisiert sich automatisch
</footer>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% from "_macros.html" import change_row %}
{% block title %}Änderungen · EPI-Support{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h2>Änderungen</h2>
<div class="head-tools">
<span class="chips">
<a class="chip {{ 'on' if show != 'unseen' }}" href="{{ url_for('changes', show='all') }}">alle</a>
<a class="chip {{ 'on' if show == 'unseen' }}" href="{{ url_for('changes', show='unseen') }}">nur ungelesene ({{ unseen }})</a>
</span>
{% if unseen %}
<form method="post" action="{{ url_for('mark_read') }}">
<button class="btn-sm" type="submit">alle als gelesen</button>
</form>
{% endif %}
</div>
</div>
{% if rows %}
<ul class="feed compact">
{% for c in rows %}{{ change_row(c) }}{% endfor %}
</ul>
{% else %}
<p class="muted">{{ 'Keine ungelesenen Änderungen.' if show == 'unseen' else 'Noch keine Änderungen erfasst.' }}</p>
{% endif %}
</section>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Bestätigen · EPI-Support{% endblock %}
{% block content %}
<section class="panel confirm">
<div class="panel-head"><h2>{{ heading }}</h2></div>
<div class="confirm-body">
<p class="warn">⚠ {{ intro }}</p>
<div class="preview-box">{{ preview }}</div>
<form method="post" action="{{ action_url }}" class="confirm-actions"
onsubmit="this.querySelector('button').disabled=true;">
{% for name, value in fields.items() %}
<input type="hidden" name="{{ name }}" value="{{ value }}">
{% endfor %}
<a class="btn-ghost" href="{{ cancel_url }}">Abbrechen</a>
<button class="btn-danger" type="submit">Verbindlich an EPI senden</button>
</form>
</div>
</section>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% from "_macros.html" import change_row %}
{% block title %}Übersicht · EPI-Support{% endblock %}
{% block content %}
<section class="cards">
<div class="card"><span class="num">{{ stats.total }}</span><span class="lbl">Tickets gesamt</span></div>
<div class="card"><span class="num">{{ stats.open }}</span><span class="lbl">offen</span></div>
<div class="card {{ 'alert' if stats.unseen else '' }}">
<span class="num">{{ stats.unseen }}</span><span class="lbl">ungelesene Änderungen</span>
</div>
<div class="card status">
{% if last_run %}
<span class="num {{ 'ok' if last_run.status == 'ok' else 'err' }}">
{{ '✓' if last_run.status == 'ok' else '!' }}
</span>
<span class="lbl">letzter Abruf<br>{{ last_run.finished_at|dt }}</span>
{% else %}
<span class="num"></span><span class="lbl">startet</span>
{% endif %}
</div>
</section>
<div class="grid">
<section class="panel">
<div class="panel-head">
<h2>Ungelesene Änderungen{% if stats.unseen %} ({{ stats.unseen }}){% endif %}</h2>
{% if stats.unseen %}
<form method="post" action="{{ url_for('mark_read') }}">
<button class="btn-sm" type="submit">alle als gelesen</button>
</form>
{% endif %}
</div>
{% if recent %}
<ul class="feed compact">
{% for c in recent %}{{ change_row(c) }}{% endfor %}
</ul>
{% if stats.unseen > recent|length %}
<a class="more" href="{{ url_for('changes', show='unseen') }}">
+ {{ stats.unseen - recent|length }} weitere ungelesene anzeigen →</a>
{% endif %}
{% else %}
<p class="muted">Alles gelesen keine offenen Änderungen. 🎉</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head"><h2>Status-Filter</h2></div>
<div class="chips">
<a class="chip {{ 'on' if not active_state }}" href="{{ url_for('index', sort=sort, dir=dir) }}">alle</a>
<a class="chip {{ 'on' if active_state == 'not_closed' }}"
href="{{ url_for('index', state='not_closed', sort=sort, dir=dir) }}">nicht geschlossen</a>
{% for s in states %}
<a class="chip {{ 'on' if active_state == s.state }}"
href="{{ url_for('index', state=s.state, sort=sort, dir=dir) }}">{{ s.state }} ({{ s.c }})</a>
{% endfor %}
</div>
</section>
</div>
{% macro th(col, label) %}
{% set nextdir = 'asc' if (sort == col and dir == 'desc') else 'desc' %}
<th class="{{ 'sorted' if sort == col }}">
<a href="{{ url_for('index', state=active_state or none, sort=col, dir=nextdir) }}">
{{ label }}{% if sort == col %} <span class="arrow">{{ '▼' if dir == 'desc' else '▲' }}</span>{% endif %}
</a>
</th>
{% endmacro %}
<section class="panel">
<div class="panel-head">
<h2>Tickets{% if active_state == 'not_closed' %} nicht geschlossen{% elif active_state %} {{ active_state }}{% endif %}</h2>
</div>
<table class="tickets">
<thead>
<tr>
{{ th('number', '#') }}{{ th('title', 'Titel') }}{{ th('state', 'Status') }}
{{ th('priority', 'Priorität') }}{{ th('owner', 'Bearbeiter') }}
{{ th('articles', 'Artikel') }}{{ th('updated', 'zuletzt geändert') }}
</tr>
</thead>
<tbody>
{% for t in tickets %}
{% set n = unseen_map.get(t.id) %}
<tr class="{{ 'row-unseen' if n }}">
<td>
{% if n %}<span class="badge" title="{{ n }} unbestätigte Änderung(en)">{{ n }}</span>{% endif %}
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}">{{ t.number }}</a>
</td>
<td class="title">{{ t.title }}</td>
<td><span class="state state-{{ t.state }}">{{ t.state }}</span></td>
<td>{{ t.priority }}</td>
<td>{{ t.owner if t.owner and t.owner != '-' else '—' }}</td>
<td>{{ t.article_count }}</td>
<td><time>{{ t.zammad_updated_at|dt }}</time></td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">Keine Tickets.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Neues Ticket · EPI-Support{% endblock %}
{% block content %}
<p><a class="back" href="{{ url_for('index') }}">← zurück zur Übersicht</a></p>
<section class="panel">
<div class="panel-head"><h2>Neues Ticket beim EPI-Support anlegen</h2></div>
<form method="post" action="{{ url_for('new_ticket_preview') }}" class="form">
<label>Titel / Betreff
<input type="text" name="title" required maxlength="200"
placeholder="Kurze Zusammenfassung des Anliegens">
</label>
<label>Gruppe
<select name="group" required>
{% for g in groups %}
<option value="{{ g }}" {{ 'selected' if g == 'EPI::Support' }}>{{ g }}</option>
{% endfor %}
</select>
</label>
<label>Nachricht
<textarea name="body" rows="10" required
placeholder="Beschreibe dein Anliegen…"></textarea>
</label>
<div class="form-actions">
<button class="btn-sm" type="submit">Weiter zur Bestätigung →</button>
</div>
</form>
</section>
{% endblock %}
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}#{{ ticket.number }} · EPI-Support{% endblock %}
{% block content %}
<p><a class="back" href="{{ url_for('index') }}">← zurück zur Übersicht</a></p>
<section class="panel">
<div class="panel-head">
<h2>#{{ ticket.number }} {{ ticket.title }}</h2>
<a class="btn-sm" href="{{ zammad_url }}/#ticket/zoom/{{ ticket.id }}"
target="_blank" rel="noopener">in Zammad öffnen ↗</a>
</div>
<dl class="meta">
<div><dt>Status</dt><dd><span class="state state-{{ ticket.state }}">{{ ticket.state }}</span></dd></div>
<div><dt>Priorität</dt><dd>{{ ticket.priority }}</dd></div>
<div><dt>Bearbeiter</dt><dd>{{ ticket.owner if ticket.owner and ticket.owner != '-' else '—' }}</dd></div>
<div><dt>Gruppe</dt><dd>{{ ticket.group }}</dd></div>
<div><dt>Artikel</dt><dd>{{ ticket.article_count }}</dd></div>
<div><dt>erstellt</dt><dd>{{ ticket.zammad_created_at|dt }}</dd></div>
<div><dt>zuletzt geändert</dt><dd>{{ ticket.zammad_updated_at|dt }}</dd></div>
<div><dt>geschlossen</dt><dd>{{ ticket.last_close_at|dt }}</dd></div>
</dl>
</section>
<section class="panel">
<div class="panel-head">
<h2>Konversation{% if conversation %} ({{ conversation|length }}){% endif %}</h2>
</div>
{% if conv_error %}
<p class="muted">Konversation konnte nicht geladen werden: {{ conv_error }}</p>
{% elif conversation %}
<div class="thread">
{% for a in conversation %}
<article class="msg msg-{{ a.sender|lower if a.sender else 'system' }}">
<header class="msg-head">
<span class="who">
<span class="role role-{{ a.sender|lower if a.sender else 'system' }}">{{ a.sender or 'System' }}</span>
{{ a.frm }}
</span>
<time>{{ a.created_at|dt }}</time>
</header>
{% if a.subject %}<div class="msg-subj">{{ a.subject }}</div>{% endif %}
<div class="msg-body">{{ a.body }}</div>
{% if a.attachments %}
<div class="msg-att">📎 {{ a.attachments|join(', ') }}</div>
{% endif %}
</article>
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Nachrichten vorhanden.</p>
{% endif %}
{% if ticket.state != 'closed' %}
<form method="post" action="{{ url_for('reply_preview', ticket_id=ticket.id) }}" class="reply-form">
<textarea name="body" rows="4" required
placeholder="Antwort an den EPI-Support verfassen…"></textarea>
<div class="form-actions">
<span class="hint-inline">Geht nach Bestätigung live an EPI.</span>
<button class="btn-sm" type="submit">Antworten →</button>
</div>
</form>
{% else %}
<p class="muted">Ticket ist geschlossen zum Antworten ggf. ein neues Ticket anlegen.</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head"><h2>Verlauf (von uns erfasst)</h2></div>
{% if timeline %}
<ul class="timeline">
{% for c in timeline %}
<li>
<span class="tag tag-{{ c.field }}">{{ c.label }}</span>
<div class="chg">{{ c.old_value or '' }} → <b>{{ c.new_value or '' }}</b>
{% if c.detail %}<div class="detail">{{ c.detail }}</div>{% endif %}
</div>
<time>{{ c.detected_at|dt }}</time>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Seit Beobachtungsbeginn keine Änderung erfasst.</p>
{% endif %}
</section>
{% endblock %}
+6
View File
@@ -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)