Files
EpiSupport-Zammad-Scraper/app/web.py
T
2026-06-29 09:52:02 +02:00

328 lines
13 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 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()