Files
EpiSupport-Zammad-Scraper/app/web.py
T
leopold.stroblandClaude Opus 4.8 1041f283a4 Add EPI-Support monitoring & analytics platform
Internal Flask app that monitors the epi-helpdesk.zammad.com tickets via
the Zammad API, deployable as a systemd service behind Apache under
/episupport.

Features:
- Poller: session-login API sync, snapshot diff to detect agent changes
  (status, owner, priority, closures, new replies); email notifications
- Dashboard: ticket overview with sorting, "not closed" filter and
  highlighting of tickets with unconfirmed changes; per-item and bulk
  "mark as read"
- Analytics: KPIs, response/resolution times, monthly SVG trend chart,
  distributions (status/priority/group/agent), Excel export
- Conversation view: full per-ticket thread cached in the DB (avoids
  proxy timeouts), inline image attachments, safe HTML-to-text rendering
- Write actions: reply to tickets and create new tickets with a
  confirmation step and attachment upload (incl. clipboard paste);
  self-triggered actions are synced back so they aren't flagged as changes
- Local timezone display (Europe/Berlin) for all timestamps

Includes deploy tooling: setup.sh, systemd units and Apache config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:19:53 +02:00

385 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from flask import (Flask, Response, abort, flash, redirect, render_template,
request, url_for)
from . import analytics, charts, config, conversation, db, export, poller, uploads
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.web")
try:
_DISPLAY_TZ = ZoneInfo(config.DISPLAY_TZ)
except (ZoneInfoNotFoundError, ValueError):
log.warning("Zeitzone %s nicht verfügbar zeige UTC.", config.DISPLAY_TZ)
_DISPLAY_TZ = timezone.utc
# Lazy initialisierter Zammad-Client für Live-Abrufe (Konversation).
_client = None
def _zammad():
global _client
if _client is None:
_client = ZammadClient()
return _client
def _sync_own_action(ticket_id):
"""Nach eigener Antwort/Neuanlage das Ticket sofort in die DB übernehmen,
damit die selbst ausgelöste Änderung nicht als Änderung gemeldet wird."""
try:
poller.sync_ticket(_zammad(), ticket_id)
except Exception as e: # noqa: BLE001 - darf den Sendevorgang nie stören
log.warning("sync_ticket nach eigener Aktion fehlgeschlagen (%s): %s",
ticket_id, e)
def _load_conversation(ticket_id):
"""Konversation aus der DB laden. Ist noch nichts gespeichert, einmalig
live nachladen (kurzer Timeout, damit die Seite nicht hängt) und cachen.
Gibt (articles, error) zurück."""
arts = conversation.load_articles(ticket_id)
if arts:
return arts, None
try:
conversation.store_articles(_zammad(), ticket_id, timeout=20)
except ZammadError as e:
return [], str(e)
return conversation.load_articles(ticket_id), 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.config["MAX_CONTENT_LENGTH"] = 25 * 1024 * 1024 # max. 25 MB Upload
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config.URL_PREFIX)
db.init_db()
# ------------------------------------------------------------- Filter
@app.template_filter("dt")
def _dt(value):
"""UTC-Zeitstempel in lokaler Zeitzone (Standard Europe/Berlin) anzeigen."""
if not value:
return ""
try:
s = str(value).replace("Z", "+00:00")
d = datetime.fromisoformat(s)
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
return d.astimezone(_DISPLAY_TZ).strftime("%d.%m.%Y %H:%M")
except (ValueError, TypeError):
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)
@app.route("/ticket/<int:ticket_id>/article/<int:article_id>/att/<int:att_id>")
def attachment(ticket_id, article_id, att_id):
meta = conversation.find_attachment(article_id, att_id)
if not meta or meta["id"] is None:
abort(404)
try:
data = conversation.get_attachment_bytes(
_zammad(), ticket_id, article_id, att_id)
except ZammadError:
abort(502)
return Response(
data,
mimetype=meta["mime"] or "application/octet-stream",
headers={"Content-Disposition":
f'inline; filename="{meta["filename"]}"'},
)
@app.route("/ticket/<int:ticket_id>/refresh", methods=["POST"])
def refresh_conversation(ticket_id):
try:
conversation.store_articles(_zammad(), ticket_id, timeout=25)
flash("Konversation aktualisiert.")
except ZammadError as e:
flash("Aktualisierung fehlgeschlagen: " + str(e))
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
# ----------------------------------------------------- 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))
uploads.cleanup_stale()
token, att_names = uploads.save_pending(request.files.getlist("files"))
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,
attachments=att_names,
action_url=url_for("reply_send", ticket_id=ticket_id),
cancel_url=url_for("ticket_detail", ticket_id=ticket_id),
fields={"body": body, "token": token or ""},
)
@app.route("/ticket/<int:ticket_id>/reply/send", methods=["POST"])
def reply_send(ticket_id):
body = (request.form.get("body") or "").strip()
token = request.form.get("token") or ""
if not body:
uploads.discard(token)
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
try:
_zammad().create_article(ticket_id, body, uploads.load_attachments(token))
_sync_own_action(ticket_id)
flash("✓ Antwort wurde an EPI gesendet.")
except ZammadError as e:
flash("✗ Fehler beim Senden: " + str(e))
finally:
uploads.discard(token)
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"))
uploads.cleanup_stale()
token, att_names = uploads.save_pending(request.files.getlist("files"))
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,
attachments=att_names,
action_url=url_for("new_ticket_send"),
cancel_url=url_for("new_ticket_form"),
fields={"title": title, "group": group, "body": body, "token": token or ""},
)
@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()
token = request.form.get("token") or ""
if not (title and group and body):
uploads.discard(token)
return redirect(url_for("new_ticket_form"))
try:
res = _zammad().create_ticket(title, group, body,
uploads.load_attachments(token))
flash(f"✓ Ticket #{res.get('number')} wurde bei EPI angelegt.")
tid = res.get("id")
if tid:
_sync_own_action(tid)
return redirect(url_for("ticket_detail", ticket_id=tid))
except ZammadError as e:
flash("✗ Fehler beim Anlegen: " + str(e))
finally:
uploads.discard(token)
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()