first commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
venv/
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/
|
||||
Vendored
+19
@@ -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/**"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Einstiegspunkt für den Poller-Dienst (Hintergrund-Abruf)."""
|
||||
from app.poller import run_forever
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_forever()
|
||||
@@ -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; }
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 Support 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 ↗</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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user