first commit
This commit is contained in:
+327
@@ -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()
|
||||
Reference in New Issue
Block a user