"""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, timedelta, 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 # Über die Oberfläche setzbare Status (Name -> deutsche Beschriftung) STATE_LABELS = { "open": "Offen", "in Bearbeitung": "In Bearbeitung", "pending close": "Warten auf Schließen", "closed": "Geschlossen", } _states_cache = None def _settable_states(): """Aktive, für den Nutzer sinnvolle Status – einmal von Zammad geholt.""" global _states_cache if _states_cache is None: by_name = {} try: for s in _zammad().ticket_states(): if s.get("active"): by_name[s.get("name")] = s except Exception as e: # noqa: BLE001 log.warning("ticket_states nicht abrufbar: %s", e) out = [{"id": by_name[n]["id"], "name": n, "label": lbl} for n, lbl in STATE_LABELS.items() if n in by_name] if not out: # Rückfall, falls Abruf scheitert fb = {"open": 2, "in Bearbeitung": 8, "pending close": 7, "closed": 4} out = [{"id": i, "name": n, "label": STATE_LABELS[n]} for n, i in fb.items()] _states_cache = out return _states_cache 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" show_archived = request.args.get("archived") == "1" with db.get_conn() as conn: params = [] conds = [] if state == "not_closed": conds.append("state NOT IN ('closed','merged','removed')") elif state: conds.append("state = ?") params.append(state) # abgelegte ("wirklich erledigte") Tickets standardmäßig ausblenden if not show_archived: conds.append("archived = 0") where = ("WHERE " + " AND ".join(conds)) if conds else "" # 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() archived_count = conn.execute( "SELECT COUNT(*) c FROM tickets WHERE archived=1" ).fetchone()["c"] 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, show_archived=show_archived, archived_count=archived_count) @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, settable_states=_settable_states()) @app.route("/ticket//state", methods=["POST"]) def set_state(ticket_id): try: state_id = int(request.form.get("state_id", "")) except ValueError: flash("Ungültiger Status.") return redirect(url_for("ticket_detail", ticket_id=ticket_id)) states = _settable_states() target = next((s for s in states if s["id"] == state_id), None) if not target: flash("Dieser Status ist nicht zulässig.") return redirect(url_for("ticket_detail", ticket_id=ticket_id)) # "Warten auf Schließen" ist ein Pending-Status und braucht einen Zeitpunkt pending = None if target["name"] == "pending close": pending = (datetime.now(timezone.utc) + timedelta(days=7) ).replace(microsecond=0).isoformat() try: _zammad().set_state(ticket_id, state_id, pending) _sync_own_action(ticket_id) flash(f"✓ Status auf „{target['label']}“ gesetzt.") except ZammadError as e: flash("✗ Status konnte nicht gesetzt werden: " + str(e)) return redirect(url_for("ticket_detail", ticket_id=ticket_id)) @app.route("/ticket//archive", methods=["POST"]) def archive_ticket(ticket_id): with db.get_conn() as conn: conn.execute("UPDATE tickets SET archived=1 WHERE id=?", (ticket_id,)) flash("Ticket als wirklich erledigt abgelegt (ausgeblendet).") # von der Detailseite zurück zur Übersicht, sonst zur Referrer-Seite ref = request.referrer or "" if f"/ticket/{ticket_id}" in ref: return redirect(url_for("index")) return redirect(ref or url_for("index")) @app.route("/ticket//unarchive", methods=["POST"]) def unarchive_ticket(ticket_id): with db.get_conn() as conn: conn.execute("UPDATE tickets SET archived=0 WHERE id=?", (ticket_id,)) flash("Ticket wieder eingeblendet.") return redirect(request.referrer or url_for("ticket_detail", ticket_id=ticket_id)) @app.route("/ticket//article//att/") 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//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//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//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//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()