"""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/") 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//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//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//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()