first commit

This commit is contained in:
2026-06-29 09:52:02 +02:00
commit da2f7c3c86
31 changed files with 2306 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
"""Flask-Dashboard für den EPI-Support-Monitor.
Läuft hinter Apache unter dem Pfad EPISUPPORT_URL_PREFIX (z. B. /episupport).
Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit
Präfix erzeugt.
"""
import logging
from flask import (Flask, Response, abort, flash, redirect, render_template,
request, url_for)
from . import analytics, charts, config, db, export, textutil
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.web")
# Lazy initialisierter Zammad-Client für Live-Abrufe (Konversation).
_client = None
def _zammad():
global _client
if _client is None:
_client = ZammadClient()
return _client
def _load_conversation(ticket_id):
"""Holt die Artikel eines Tickets live und bereitet sie auf.
Gibt (articles, error) zurück."""
try:
raw = _zammad().ticket_articles(ticket_id)
except ZammadError as e:
return [], str(e)
out = []
for a in raw:
body = a.get("body") or ""
if (a.get("content_type") or "").startswith("text/html"):
body = textutil.clean_html(body)
atts = [x.get("filename") for x in (a.get("attachments") or [])
if x.get("filename") and x.get("filename") != "message.html"]
out.append({
"id": a.get("id"),
"sender": a.get("sender"), # Customer / Agent / System
"frm": a.get("from") or "",
"created_at": a.get("created_at"),
"subject": a.get("subject"),
"internal": a.get("internal"),
"body": body,
"attachments": atts,
})
return out, None
class PrefixMiddleware:
"""Setzt SCRIPT_NAME, damit url_for hinter dem Apache-Subpfad stimmt."""
def __init__(self, app, prefix=""):
self.app = app
self.prefix = prefix
def __call__(self, environ, start_response):
if self.prefix:
environ["SCRIPT_NAME"] = self.prefix
path = environ.get("PATH_INFO", "")
if path.startswith(self.prefix):
environ["PATH_INFO"] = path[len(self.prefix):]
return self.app(environ, start_response)
def create_app() -> Flask:
app = Flask(__name__, template_folder="../templates", static_folder="../static")
app.secret_key = "episupport-local" # nur für Flash-Messages, keine Logins
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config.URL_PREFIX)
db.init_db()
# ------------------------------------------------------------- Filter
@app.template_filter("dt")
def _dt(value):
if not value:
return ""
return str(value).replace("T", " ")[:16]
@app.context_processor
def _inject():
return {"zammad_url": config.ZAMMAD_URL}
# ------------------------------------------------------------- Routen
# erlaubte Sortierspalten (Anzeigename -> DB-Spalte)
SORT_COLS = {
"number": "number", "title": "title", "state": "state",
"priority": "priority_id", "owner": "owner",
"articles": "article_count", "updated": "zammad_updated_at",
"created": "zammad_created_at",
}
@app.route("/")
def index():
state = request.args.get("state", "")
sort = request.args.get("sort", "updated")
if sort not in SORT_COLS:
sort = "updated"
direction = "asc" if request.args.get("dir") == "asc" else "desc"
with db.get_conn() as conn:
params = []
where = ""
if state == "not_closed":
where = "WHERE state NOT IN ('closed','merged','removed')"
elif state:
where = "WHERE state = ?"
params.append(state)
# Tickets mit unbestätigten (ungelesenen) Änderungen
unseen_map = {
r["ticket_id"]: r["c"] for r in conn.execute(
"SELECT ticket_id, COUNT(*) c FROM changes WHERE seen=0 "
"GROUP BY ticket_id"
).fetchall()
}
col = SORT_COLS[sort]
# Tickets mit offenen Änderungen immer zuerst, dann nach Sortierwahl
order = (f'CASE WHEN id IN (SELECT DISTINCT ticket_id FROM changes '
f'WHERE seen=0) THEN 0 ELSE 1 END, {col} {direction.upper()}')
tickets = conn.execute(
f"SELECT * FROM tickets {where} ORDER BY {order}", params
).fetchall()
states = conn.execute(
"SELECT state, COUNT(*) c FROM tickets GROUP BY state ORDER BY c DESC"
).fetchall()
stats = {
"total": conn.execute("SELECT COUNT(*) c FROM tickets").fetchone()["c"],
"open": conn.execute(
"SELECT COUNT(*) c FROM tickets WHERE state NOT IN ('closed','merged','removed')"
).fetchone()["c"],
"unseen": conn.execute(
"SELECT COUNT(*) c FROM changes WHERE seen=0"
).fetchone()["c"],
}
recent = conn.execute(
"SELECT * FROM changes WHERE seen=0 ORDER BY detected_at DESC LIMIT 8"
).fetchall()
last_run = conn.execute(
"SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1"
).fetchone()
return render_template("index.html", tickets=tickets, states=states,
stats=stats, recent=recent, last_run=last_run,
active_state=state, sort=sort, dir=direction,
unseen_map=unseen_map)
@app.route("/ticket/<int:ticket_id>")
def ticket_detail(ticket_id):
with db.get_conn() as conn:
ticket = conn.execute(
"SELECT * FROM tickets WHERE id=?", (ticket_id,)
).fetchone()
if ticket is None:
abort(404)
timeline = conn.execute(
"SELECT * FROM changes WHERE ticket_id=? ORDER BY detected_at DESC",
(ticket_id,)
).fetchall()
conversation, conv_error = _load_conversation(ticket_id)
return render_template("ticket.html", ticket=ticket, timeline=timeline,
conversation=conversation, conv_error=conv_error)
# ----------------------------------------------------- Antworten (live)
@app.route("/ticket/<int:ticket_id>/reply", methods=["POST"])
def reply_preview(ticket_id):
body = (request.form.get("body") or "").strip()
with db.get_conn() as conn:
ticket = conn.execute("SELECT number, title FROM tickets WHERE id=?",
(ticket_id,)).fetchone()
if not body:
flash("Bitte zuerst einen Text eingeben.")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
return render_template(
"confirm.html",
heading="Antwort an EPI senden?",
intro=f"Diese Antwort wird live an den EPI-Support zu Ticket "
f"#{ticket['number'] if ticket else ticket_id} übermittelt:",
preview=body,
action_url=url_for("reply_send", ticket_id=ticket_id),
cancel_url=url_for("ticket_detail", ticket_id=ticket_id),
fields={"body": body},
)
@app.route("/ticket/<int:ticket_id>/reply/send", methods=["POST"])
def reply_send(ticket_id):
body = (request.form.get("body") or "").strip()
if not body:
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
try:
_zammad().create_article(ticket_id, body)
flash("✓ Antwort wurde an EPI gesendet.")
except ZammadError as e:
flash("✗ Fehler beim Senden: " + str(e))
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
# -------------------------------------------------- Neues Ticket (live)
def _known_groups():
with db.get_conn() as conn:
rows = conn.execute(
'SELECT DISTINCT "group" g FROM tickets WHERE "group" IS NOT NULL '
'ORDER BY g').fetchall()
return [r["g"] for r in rows] or ["EPI::Support"]
@app.route("/new")
def new_ticket_form():
return render_template("new_ticket.html", groups=_known_groups())
@app.route("/new/preview", methods=["POST"])
def new_ticket_preview():
title = (request.form.get("title") or "").strip()
group = (request.form.get("group") or "").strip()
body = (request.form.get("body") or "").strip()
if not (title and group and body):
flash("Bitte Titel, Gruppe und Nachricht ausfüllen.")
return redirect(url_for("new_ticket_form"))
return render_template(
"confirm.html",
heading="Neues Ticket bei EPI anlegen?",
intro=f"Es wird ein neues Ticket in der Gruppe „{group}“ mit dem "
f"Titel „{title}“ live beim EPI-Support angelegt:",
preview=body,
action_url=url_for("new_ticket_send"),
cancel_url=url_for("new_ticket_form"),
fields={"title": title, "group": group, "body": body},
)
@app.route("/new/send", methods=["POST"])
def new_ticket_send():
title = (request.form.get("title") or "").strip()
group = (request.form.get("group") or "").strip()
body = (request.form.get("body") or "").strip()
if not (title and group and body):
return redirect(url_for("new_ticket_form"))
try:
res = _zammad().create_ticket(title, group, body)
flash(f"✓ Ticket #{res.get('number')} wurde bei EPI angelegt.")
tid = res.get("id")
if tid:
return redirect(url_for("ticket_detail", ticket_id=tid))
except ZammadError as e:
flash("✗ Fehler beim Anlegen: " + str(e))
return redirect(url_for("new_ticket_form"))
@app.route("/changes")
def changes():
show = request.args.get("show", "all")
with db.get_conn() as conn:
if show == "unseen":
rows = conn.execute(
"SELECT * FROM changes WHERE seen=0 ORDER BY detected_at DESC LIMIT 300"
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM changes ORDER BY detected_at DESC LIMIT 300"
).fetchall()
unseen = conn.execute(
"SELECT COUNT(*) c FROM changes WHERE seen=0").fetchone()["c"]
return render_template("changes.html", rows=rows, show=show, unseen=unseen)
@app.route("/changes/mark-read", methods=["POST"])
def mark_read():
with db.get_conn() as conn:
conn.execute("UPDATE changes SET seen=1 WHERE seen=0")
flash("Alle Änderungen als gelesen markiert.")
return redirect(request.referrer or url_for("index"))
@app.route("/changes/<int:change_id>/seen", methods=["POST"])
def mark_one_read(change_id):
with db.get_conn() as conn:
conn.execute("UPDATE changes SET seen=1 WHERE id=?", (change_id,))
return redirect(request.referrer or url_for("index"))
PERIODS = [("30", "30 Tage"), ("90", "90 Tage"), ("180", "6 Monate"),
("365", "12 Monate"), ("all", "gesamt")]
def _period_days():
p = request.args.get("period", "365")
if p == "all":
return None, "all"
try:
return int(p), p
except ValueError:
return 365, "365"
@app.route("/analytics")
def analytics_view():
days, active = _period_days()
m = analytics.compute(days)
chart_svg = charts.monthly_chart(m["monthly"])
return render_template("analytics.html", m=m, chart_svg=chart_svg,
periods=PERIODS, active_period=active, h=m["humanize"])
@app.route("/export.xlsx")
def export_xlsx():
days, active = _period_days()
data = export.build_workbook(days)
fname = f"epi-support-auswertung-{active}.xlsx"
return Response(
data,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@app.route("/health")
def health():
with db.get_conn() as conn:
last = conn.execute(
"SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1"
).fetchone()
if last is None:
return {"status": "starting"}, 200
return {
"status": last["status"],
"last_run": last["finished_at"],
"tickets": last["ticket_count"],
}, 200
return app
app = create_app()