Add EPI-Support monitoring & analytics platform
Internal Flask app that monitors the epi-helpdesk.zammad.com tickets via the Zammad API, deployable as a systemd service behind Apache under /episupport. Features: - Poller: session-login API sync, snapshot diff to detect agent changes (status, owner, priority, closures, new replies); email notifications - Dashboard: ticket overview with sorting, "not closed" filter and highlighting of tickets with unconfirmed changes; per-item and bulk "mark as read" - Analytics: KPIs, response/resolution times, monthly SVG trend chart, distributions (status/priority/group/agent), Excel export - Conversation view: full per-ticket thread cached in the DB (avoids proxy timeouts), inline image attachments, safe HTML-to-text rendering - Write actions: reply to tickets and create new tickets with a confirmation step and attachment upload (incl. clipboard paste); self-triggered actions are synced back so they aren't flagged as changes - Local timezone display (Europe/Berlin) for all timestamps Includes deploy tooling: setup.sh, systemd units and Apache config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,8 @@ POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "180"))
|
||||
# --- Web ---
|
||||
URL_PREFIX = os.getenv("EPISUPPORT_URL_PREFIX", "/episupport").rstrip("/")
|
||||
PORT = int(os.getenv("EPISUPPORT_PORT", "8071"))
|
||||
# Zeitzone für die Anzeige (Zammad liefert UTC)
|
||||
DISPLAY_TZ = os.getenv("DISPLAY_TZ", "Europe/Berlin")
|
||||
|
||||
# --- E-Mail ---
|
||||
NOTIFY_ENABLED = _b(os.getenv("NOTIFY_ENABLED"), False)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Konversation (Artikel) eines Tickets: in der DB zwischenspeichern und laden.
|
||||
|
||||
So muss die Leseseite nicht live auf Zammad warten (vermeidet Proxy-Timeouts).
|
||||
Der Poller füllt/aktualisiert die Artikel im Hintergrund; die Webseite liest
|
||||
nur aus der DB (mit einmaligem Lazy-Load als Rückfall).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import config, db, textutil
|
||||
|
||||
log = logging.getLogger("episupport.conversation")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
ATT_CACHE = config.DATA_DIR / "att_cache"
|
||||
ATT_CACHE.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def _attachment_meta(article) -> list[dict]:
|
||||
"""Relevante Anhang-Metadaten (id, filename, mime) je Artikel."""
|
||||
out = []
|
||||
for x in (article.get("attachments") or []):
|
||||
fn = x.get("filename")
|
||||
if not fn or fn == "message.html":
|
||||
continue
|
||||
prefs = x.get("preferences") or {}
|
||||
mime = prefs.get("Mime-Type") or prefs.get("Content-Type") or ""
|
||||
out.append({"id": x.get("id"), "filename": fn, "mime": mime})
|
||||
return out
|
||||
|
||||
|
||||
def save_articles(ticket_id: int, articles: list[dict], conn=None) -> int:
|
||||
"""Bereits abgerufene Artikel in die DB schreiben (INSERT OR REPLACE).
|
||||
|
||||
Wird `conn` übergeben, wird diese bestehende Verbindung genutzt (wichtig
|
||||
im Poller, der bereits eine Transaktion offen hält – sonst „database is
|
||||
locked"). Ohne `conn` wird eine eigene, kurze Transaktion verwendet.
|
||||
"""
|
||||
now = _now()
|
||||
|
||||
def _write(c):
|
||||
for a in articles:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO articles (article_id, ticket_id, sender, "
|
||||
"frm, subject, body, content_type, internal, created_at, "
|
||||
"attachments, fetched_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(a.get("id"), ticket_id, a.get("sender"), a.get("from"),
|
||||
a.get("subject"), a.get("body"), a.get("content_type"),
|
||||
1 if a.get("internal") else 0, a.get("created_at"),
|
||||
json.dumps(_attachment_meta(a)), now),
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
_write(conn)
|
||||
else:
|
||||
with db.get_conn() as own:
|
||||
_write(own)
|
||||
return len(articles)
|
||||
|
||||
|
||||
def store_articles(client, ticket_id: int, timeout: int | None = None,
|
||||
conn=None) -> int:
|
||||
"""Artikel live von Zammad holen und speichern."""
|
||||
articles = client.ticket_articles(ticket_id, timeout=timeout)
|
||||
return save_articles(ticket_id, articles, conn=conn)
|
||||
|
||||
|
||||
def load_articles(ticket_id: int) -> list[dict]:
|
||||
"""Aufbereitete Artikel aus der DB (HTML sicher zu Text bereinigt)."""
|
||||
with db.get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles WHERE ticket_id=? ORDER BY created_at, article_id",
|
||||
(ticket_id,)
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
body = r["body"] or ""
|
||||
if (r["content_type"] or "").startswith("text/html"):
|
||||
body = textutil.clean_html(body)
|
||||
out.append({
|
||||
"id": r["article_id"],
|
||||
"sender": r["sender"],
|
||||
"frm": r["frm"] or "",
|
||||
"subject": r["subject"],
|
||||
"created_at": r["created_at"],
|
||||
"internal": r["internal"],
|
||||
"body": body,
|
||||
"attachments": _parse_attachments(r["attachments"]),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_attachments(raw) -> list[dict]:
|
||||
"""Verträgt altes Format (nur Dateinamen) und neues (id/filename/mime)."""
|
||||
items = json.loads(raw or "[]")
|
||||
out = []
|
||||
for x in items:
|
||||
if isinstance(x, str): # altes Cache-Format
|
||||
out.append({"id": None, "filename": x, "mime": "", "is_image": False})
|
||||
else:
|
||||
mime = x.get("mime") or ""
|
||||
out.append({
|
||||
"id": x.get("id"),
|
||||
"filename": x.get("filename"),
|
||||
"mime": mime,
|
||||
"is_image": mime.startswith("image/"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def find_attachment(article_id: int, att_id: int) -> dict | None:
|
||||
"""Metadaten eines Anhangs (Mime/Dateiname) aus der DB – für die Auslieferung."""
|
||||
with db.get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT attachments FROM articles WHERE article_id=?", (article_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
for a in _parse_attachments(row["attachments"]):
|
||||
if a["id"] == att_id:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def get_attachment_bytes(client, ticket_id: int, article_id: int, att_id: int) -> bytes:
|
||||
"""Anhang-Bytes liefern; einmal von Zammad holen und auf Platte cachen."""
|
||||
cache = ATT_CACHE / f"{article_id}_{att_id}"
|
||||
if cache.exists():
|
||||
return cache.read_bytes()
|
||||
data = client.get_attachment(ticket_id, article_id, att_id)
|
||||
try:
|
||||
cache.write_bytes(data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def has_articles(ticket_id: int) -> bool:
|
||||
with db.get_conn() as conn:
|
||||
return conn.execute(
|
||||
"SELECT 1 FROM articles WHERE ticket_id=? LIMIT 1", (ticket_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def tickets_without_articles(limit: int = 10) -> list[int]:
|
||||
with db.get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM tickets WHERE id NOT IN "
|
||||
"(SELECT DISTINCT ticket_id FROM articles) "
|
||||
"ORDER BY zammad_updated_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
@@ -53,6 +53,22 @@ CREATE INDEX IF NOT EXISTS idx_changes_detected ON changes(detected_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_ticket ON changes(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_seen ON changes(seen);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
article_id INTEGER PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
sender TEXT,
|
||||
frm TEXT,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
content_type TEXT,
|
||||
internal INTEGER,
|
||||
created_at TEXT,
|
||||
attachments TEXT,
|
||||
fetched_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_ticket ON articles(ticket_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT,
|
||||
|
||||
+41
-1
@@ -5,7 +5,7 @@ import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import config, db, notify
|
||||
from . import config, conversation, db, notify
|
||||
from .zammad import ZammadClient, ZammadError
|
||||
|
||||
log = logging.getLogger("episupport.poller")
|
||||
@@ -111,7 +111,12 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
|
||||
new_count = new_t.get("article_count") or 0
|
||||
if new_count > old_count:
|
||||
detail = None
|
||||
try:
|
||||
articles = client.ticket_articles(new_t["id"])
|
||||
conversation.save_articles(new_t["id"], articles, conn=conn) # cachen
|
||||
except ZammadError as e:
|
||||
log.warning("Artikel für Ticket %s nicht abrufbar: %s", new_t["id"], e)
|
||||
articles = []
|
||||
if articles:
|
||||
last = articles[-1]
|
||||
sender = last.get("sender") # Agent / Customer / System
|
||||
@@ -133,6 +138,35 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
|
||||
return found
|
||||
|
||||
|
||||
def sync_ticket(client: ZammadClient, ticket_id: int) -> None:
|
||||
"""Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen.
|
||||
|
||||
Wird nach einer EIGENEN Aktion (Antwort/Neuanlage über den Client) gerufen,
|
||||
damit der nächste Poll-Durchlauf keinen Unterschied sieht und die selbst
|
||||
ausgelöste Änderung weder im Feed erscheint noch eine Mail auslöst.
|
||||
"""
|
||||
t = client.get_ticket(ticket_id)
|
||||
with db.get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tickets WHERE id=?", (ticket_id,)
|
||||
).fetchone()
|
||||
_upsert(conn, _map_ticket(t), is_new=(existing is None))
|
||||
try:
|
||||
conversation.store_articles(client, ticket_id)
|
||||
except ZammadError as e:
|
||||
log.warning("Artikel-Sync nach eigener Aktion (Ticket %s): %s", ticket_id, e)
|
||||
|
||||
|
||||
def _backfill_articles(client: ZammadClient, limit: int = 10) -> None:
|
||||
"""Pro Durchlauf einige Tickets ohne gespeicherte Konversation nachladen,
|
||||
damit die Leseansicht nach und nach komplett aus der DB bedient wird."""
|
||||
for tid in conversation.tickets_without_articles(limit):
|
||||
try:
|
||||
conversation.store_articles(client, tid)
|
||||
except ZammadError as e:
|
||||
log.warning("Backfill für Ticket %s fehlgeschlagen: %s", tid, e)
|
||||
|
||||
|
||||
def poll_once(client: ZammadClient) -> int:
|
||||
"""Ein Abruf-Durchlauf. Gibt Anzahl erkannter Änderungen zurück."""
|
||||
started = _now()
|
||||
@@ -157,9 +191,15 @@ def poll_once(client: ZammadClient) -> int:
|
||||
all_changes.append(_record_change(
|
||||
conn, t, "new_ticket", "Neues Ticket", None,
|
||||
f"#{t.get('number')}", None))
|
||||
try:
|
||||
conversation.store_articles(client, t["id"], conn=conn)
|
||||
except ZammadError:
|
||||
pass
|
||||
else:
|
||||
all_changes.extend(_detect_changes(conn, client, t, existing))
|
||||
_upsert(conn, row, is_new=False)
|
||||
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
|
||||
_backfill_articles(client)
|
||||
except ZammadError as e:
|
||||
status = "error"
|
||||
error = str(e)
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"""Zwischenspeicher für hochgeladene Anhänge.
|
||||
|
||||
Beim Bestätigungs-Schritt werden Dateien temporär unter data/uploads/<token>
|
||||
abgelegt und erst beim verbindlichen Senden gelesen und (base64) an Zammad
|
||||
übergeben. Danach wird der Zwischenspeicher gelöscht.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from . import config
|
||||
|
||||
UPLOAD_DIR = config.DATA_DIR / "uploads"
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
|
||||
_TOKEN_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
|
||||
|
||||
def _dir(token: str) -> Path | None:
|
||||
if not token or not _TOKEN_RE.match(token):
|
||||
return None
|
||||
return UPLOAD_DIR / token
|
||||
|
||||
|
||||
def save_pending(files) -> tuple[str | None, list[str]]:
|
||||
"""Hochgeladene Dateien (werkzeug FileStorage) zwischenspeichern.
|
||||
Gibt (token, Dateinamen) zurück; (None, []) wenn nichts dabei war."""
|
||||
real = [f for f in files if f and getattr(f, "filename", "")]
|
||||
if not real:
|
||||
return None, []
|
||||
token = uuid.uuid4().hex
|
||||
d = UPLOAD_DIR / token
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
manifest, names = [], []
|
||||
for i, f in enumerate(real):
|
||||
data = f.read()
|
||||
if not data:
|
||||
continue
|
||||
(d / str(i)).write_bytes(data)
|
||||
manifest.append({
|
||||
"stored": str(i),
|
||||
"filename": f.filename,
|
||||
"mimetype": getattr(f, "mimetype", None) or "application/octet-stream",
|
||||
"size": len(data),
|
||||
})
|
||||
names.append(f.filename)
|
||||
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return token, names
|
||||
|
||||
|
||||
def _manifest(token: str) -> list[dict]:
|
||||
d = _dir(token)
|
||||
if not d:
|
||||
return []
|
||||
mf = d / "manifest.json"
|
||||
if not mf.exists():
|
||||
return []
|
||||
return json.loads(mf.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def names(token: str) -> list[str]:
|
||||
return [m["filename"] for m in _manifest(token)]
|
||||
|
||||
|
||||
def load_attachments(token: str) -> list[dict]:
|
||||
"""Zammad-Inline-Format: {filename, data(base64), mime-type}."""
|
||||
d = _dir(token)
|
||||
out = []
|
||||
for m in _manifest(token):
|
||||
p = d / m["stored"]
|
||||
if not p.exists():
|
||||
continue
|
||||
out.append({
|
||||
"filename": m["filename"],
|
||||
"data": base64.b64encode(p.read_bytes()).decode("ascii"),
|
||||
"mime-type": m["mimetype"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def discard(token: str) -> None:
|
||||
d = _dir(token)
|
||||
if d and d.exists():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
def cleanup_stale(max_age_seconds: int = 3600) -> None:
|
||||
"""Verwaiste Zwischenspeicher (abgebrochene Vorgänge) aufräumen."""
|
||||
if not UPLOAD_DIR.exists():
|
||||
return
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
for d in UPLOAD_DIR.iterdir():
|
||||
try:
|
||||
if d.is_dir() and (now - d.stat().st_mtime) > max_age_seconds:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
+84
-27
@@ -5,15 +5,23 @@ Das SCRIPT_NAME-Middleware sorgt dafür, dass url_for() korrekte Links mit
|
||||
Präfix erzeugt.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from flask import (Flask, Response, abort, flash, redirect, render_template,
|
||||
request, url_for)
|
||||
|
||||
from . import analytics, charts, config, db, export, textutil
|
||||
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
|
||||
|
||||
@@ -25,31 +33,28 @@ def _zammad():
|
||||
return _client
|
||||
|
||||
|
||||
def _load_conversation(ticket_id):
|
||||
"""Holt die Artikel eines Tickets live und bereitet sie auf.
|
||||
Gibt (articles, error) zurück."""
|
||||
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:
|
||||
raw = _zammad().ticket_articles(ticket_id)
|
||||
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)
|
||||
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
|
||||
return conversation.load_articles(ticket_id), None
|
||||
|
||||
|
||||
class PrefixMiddleware:
|
||||
@@ -71,14 +76,23 @@ class PrefixMiddleware:
|
||||
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
|
||||
@@ -166,6 +180,32 @@ def create_app() -> Flask:
|
||||
return render_template("ticket.html", ticket=ticket, timeline=timeline,
|
||||
conversation=conversation, conv_error=conv_error)
|
||||
|
||||
@app.route("/ticket/<int:ticket_id>/article/<int:article_id>/att/<int:att_id>")
|
||||
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/<int:ticket_id>/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/<int:ticket_id>/reply", methods=["POST"])
|
||||
def reply_preview(ticket_id):
|
||||
@@ -176,27 +216,35 @@ def create_app() -> Flask:
|
||||
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},
|
||||
fields={"body": body, "token": token or ""},
|
||||
)
|
||||
|
||||
@app.route("/ticket/<int:ticket_id>/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)
|
||||
_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)
|
||||
@@ -219,15 +267,18 @@ def create_app() -> Flask:
|
||||
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},
|
||||
fields={"title": title, "group": group, "body": body, "token": token or ""},
|
||||
)
|
||||
|
||||
@app.route("/new/send", methods=["POST"])
|
||||
@@ -235,16 +286,22 @@ def create_app() -> Flask:
|
||||
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)
|
||||
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")
|
||||
|
||||
+42
-18
@@ -58,16 +58,17 @@ class ZammadClient:
|
||||
self._login_session()
|
||||
|
||||
# ------------------------------------------------------------- requests
|
||||
def _get(self, path: str, params: dict | None = None, _retry: bool = True):
|
||||
def _get(self, path: str, params: dict | None = None, _retry: bool = True,
|
||||
timeout: int | None = None):
|
||||
self.ensure_auth()
|
||||
url = f"{self.base}{path}"
|
||||
r = self.session.get(url, params=params, timeout=60)
|
||||
r = self.session.get(url, params=params, timeout=timeout or 60)
|
||||
if r.status_code in (401, 403) and _retry and not config.ZAMMAD_TOKEN:
|
||||
# Session evtl. abgelaufen -> einmal neu einloggen
|
||||
log.warning("Auth abgelaufen (HTTP %s), erneuter Login.", r.status_code)
|
||||
self._authed = False
|
||||
self._login_session()
|
||||
return self._get(path, params=params, _retry=False)
|
||||
return self._get(path, params=params, _retry=False, timeout=timeout)
|
||||
if r.status_code != 200:
|
||||
raise ZammadError(f"GET {path} -> HTTP {r.status_code}: {r.text[:200]}")
|
||||
return r.json()
|
||||
@@ -97,12 +98,27 @@ class ZammadClient:
|
||||
break
|
||||
return out
|
||||
|
||||
def ticket_articles(self, ticket_id: int) -> list[dict]:
|
||||
try:
|
||||
return self._get(f"/api/v1/ticket_articles/by_ticket/{ticket_id}")
|
||||
except ZammadError as e:
|
||||
log.warning("Artikel für Ticket %s nicht abrufbar: %s", ticket_id, e)
|
||||
return []
|
||||
def get_ticket(self, ticket_id: int) -> dict:
|
||||
return self._get(f"/api/v1/tickets/{ticket_id}", params={"expand": "true"})
|
||||
|
||||
def ticket_articles(self, ticket_id: int, timeout: int | None = None) -> list[dict]:
|
||||
return self._get(f"/api/v1/ticket_articles/by_ticket/{ticket_id}",
|
||||
timeout=timeout)
|
||||
|
||||
def get_attachment(self, ticket_id: int, article_id: int, att_id: int,
|
||||
timeout: int = 30) -> bytes:
|
||||
"""Rohbytes eines Anhangs herunterladen."""
|
||||
self.ensure_auth()
|
||||
url = (f"{self.base}/api/v1/ticket_attachment/"
|
||||
f"{ticket_id}/{article_id}/{att_id}")
|
||||
r = self.session.get(url, timeout=timeout)
|
||||
if r.status_code in (401, 403) and not config.ZAMMAD_TOKEN:
|
||||
self._authed = False
|
||||
self._login_session()
|
||||
r = self.session.get(url, timeout=timeout)
|
||||
if r.status_code != 200:
|
||||
raise ZammadError(f"Anhang {att_id} -> HTTP {r.status_code}")
|
||||
return r.content
|
||||
|
||||
# ----------------------------------------------------------- schreibend
|
||||
def _csrf(self) -> str | None:
|
||||
@@ -128,26 +144,34 @@ class ZammadClient:
|
||||
raise ZammadError(f"POST {path} -> HTTP {r.status_code}: {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def create_article(self, ticket_id: int, body: str) -> dict:
|
||||
def create_article(self, ticket_id: int, body: str,
|
||||
attachments: list[dict] | None = None) -> dict:
|
||||
"""Antwort/Nachricht an ein bestehendes Ticket anhängen (an EPI sichtbar)."""
|
||||
return self._post("/api/v1/ticket_articles", {
|
||||
payload = {
|
||||
"ticket_id": ticket_id,
|
||||
"body": body,
|
||||
"content_type": "text/plain",
|
||||
"type": "web",
|
||||
"internal": False,
|
||||
})
|
||||
}
|
||||
if attachments:
|
||||
payload["attachments"] = attachments
|
||||
return self._post("/api/v1/ticket_articles", payload)
|
||||
|
||||
def create_ticket(self, title: str, group: str, body: str) -> dict:
|
||||
def create_ticket(self, title: str, group: str, body: str,
|
||||
attachments: list[dict] | None = None) -> dict:
|
||||
"""Neues Ticket beim EPI-Support anlegen."""
|
||||
return self._post("/api/v1/tickets", {
|
||||
"title": title,
|
||||
"group": group,
|
||||
"article": {
|
||||
article = {
|
||||
"subject": title,
|
||||
"body": body,
|
||||
"content_type": "text/plain",
|
||||
"type": "web",
|
||||
"internal": False,
|
||||
},
|
||||
}
|
||||
if attachments:
|
||||
article["attachments"] = attachments
|
||||
return self._post("/api/v1/tickets", {
|
||||
"title": title,
|
||||
"group": group,
|
||||
"article": article,
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<Location /episupport>
|
||||
ProxyPreserveHost On
|
||||
ProxyPass http://127.0.0.1:8071/
|
||||
ProxyPass http://127.0.0.1:8071/ timeout=120
|
||||
ProxyPassReverse http://127.0.0.1:8071/
|
||||
|
||||
# Damit die App ihre Links korrekt mit /episupport-Präfix erzeugt
|
||||
|
||||
@@ -10,7 +10,9 @@ Group=www-data
|
||||
WorkingDirectory=/var/www/html/episupport
|
||||
EnvironmentFile=/var/www/html/episupport/.env
|
||||
ExecStart=/var/www/html/episupport/venv/bin/python -m gunicorn \
|
||||
--workers 2 \
|
||||
--workers 3 \
|
||||
--timeout 120 \
|
||||
--graceful-timeout 30 \
|
||||
--bind 127.0.0.1:8071 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
|
||||
@@ -3,3 +3,4 @@ requests==2.32.3
|
||||
python-dotenv==1.0.1
|
||||
gunicorn==22.0.0
|
||||
openpyxl==3.1.5
|
||||
tzdata==2024.1
|
||||
|
||||
@@ -186,6 +186,11 @@ table.kv td:first-child { font-weight: 600; }
|
||||
.msg-subj { font-weight: 600; margin: .35rem 0 .15rem; font-size: .9rem; }
|
||||
.msg-body { white-space: pre-wrap; word-wrap: break-word; font-size: .9rem; line-height: 1.45; }
|
||||
.msg-att { margin-top: .5rem; font-size: .8rem; color: var(--muted); }
|
||||
.msg-imgs { margin-top: .6rem; display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.msg-img {
|
||||
max-width: 260px; max-height: 260px; border: 1px solid var(--line);
|
||||
border-radius: 8px; object-fit: contain; background: #fff; cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* --- Formulare / Antworten --- */
|
||||
.reply-form { padding: .8rem 1.1rem 1.1rem; border-top: 1px solid var(--line); }
|
||||
@@ -208,3 +213,21 @@ textarea { resize: vertical; }
|
||||
.btn-danger:hover { background: #a13325; }
|
||||
.btn-danger:disabled { opacity: .6; cursor: default; }
|
||||
.btn-ghost { padding: .55rem 1rem; border-radius: 7px; border: 1px solid var(--line); color: #44525e; font-size: .9rem; }
|
||||
|
||||
/* --- Anhänge --- */
|
||||
.attach { display: flex; flex-direction: column; gap: .4rem; }
|
||||
.attach-label { font-size: .85rem; font-weight: 600; color: #44525e; display: flex; flex-direction: column; gap: .15rem; }
|
||||
.attach-hint { font-weight: 400; color: var(--muted); font-size: .78rem; }
|
||||
.attach-input { font: inherit; font-size: .82rem; }
|
||||
.attach-list { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
.attach-chip {
|
||||
display: inline-flex; align-items: center; gap: .3rem; background: #eef2f5;
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: .15rem .6rem;
|
||||
font-size: .8rem; color: #44525e;
|
||||
}
|
||||
.attach-chip button {
|
||||
border: 0; background: transparent; color: var(--accent); cursor: pointer;
|
||||
font-size: 1rem; line-height: 1; padding: 0;
|
||||
}
|
||||
.attach-form.dragging { outline: 2px dashed var(--brand); outline-offset: 4px; border-radius: 8px; }
|
||||
.attach-summary { margin: .4rem 0 .2rem; display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; font-size: .85rem; }
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Anhänge: Dateiauswahl, Einfügen aus Zwischenablage (Strg+V) und Drag & Drop.
|
||||
(function () {
|
||||
function initAttach(form) {
|
||||
var input = form.querySelector(".attach-input");
|
||||
var list = form.querySelector(".attach-list");
|
||||
if (!input) return;
|
||||
var dt = new DataTransfer();
|
||||
var pasteCount = 0;
|
||||
|
||||
function refresh() {
|
||||
input.files = dt.files;
|
||||
list.innerHTML = "";
|
||||
Array.prototype.forEach.call(dt.files, function (f, i) {
|
||||
var chip = document.createElement("span");
|
||||
chip.className = "attach-chip";
|
||||
chip.textContent = (f.name || "Datei") + " ";
|
||||
var x = document.createElement("button");
|
||||
x.type = "button";
|
||||
x.textContent = "×";
|
||||
x.title = "Entfernen";
|
||||
x.addEventListener("click", function () {
|
||||
var keep = new DataTransfer();
|
||||
Array.prototype.forEach.call(dt.files, function (ff, j) {
|
||||
if (j !== i) keep.items.add(ff);
|
||||
});
|
||||
dt = keep;
|
||||
refresh();
|
||||
});
|
||||
chip.appendChild(x);
|
||||
list.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function addFiles(files) {
|
||||
var added = false;
|
||||
Array.prototype.forEach.call(files, function (f) {
|
||||
if (f) { dt.items.add(f); added = true; }
|
||||
});
|
||||
if (added) refresh();
|
||||
}
|
||||
|
||||
input.addEventListener("change", function () {
|
||||
addFiles(input.files);
|
||||
});
|
||||
|
||||
form.addEventListener("paste", function (e) {
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
var imgs = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === "file") {
|
||||
var f = items[i].getAsFile();
|
||||
if (f) {
|
||||
if (!f.name || f.name === "image.png") {
|
||||
pasteCount++;
|
||||
var ext = (f.type && f.type.split("/")[1]) || "png";
|
||||
f = new File([f], "einfuegung-" + pasteCount + "." + ext, { type: f.type });
|
||||
}
|
||||
imgs.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imgs.length) { e.preventDefault(); addFiles(imgs); }
|
||||
});
|
||||
|
||||
["dragover", "dragenter"].forEach(function (ev) {
|
||||
form.addEventListener(ev, function (e) { e.preventDefault(); form.classList.add("dragging"); });
|
||||
});
|
||||
["dragleave", "drop"].forEach(function (ev) {
|
||||
form.addEventListener(ev, function (e) { e.preventDefault(); form.classList.remove("dragging"); });
|
||||
});
|
||||
form.addEventListener("drop", function (e) {
|
||||
if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("form.attach-form").forEach(initAttach);
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="attach">
|
||||
<label class="attach-label">Anhänge
|
||||
<span class="attach-hint">Screenshot mit Strg+V einfügen, Dateien hierher ziehen oder auswählen</span>
|
||||
</label>
|
||||
<input class="attach-input" type="file" name="files" multiple>
|
||||
<div class="attach-list"></div>
|
||||
</div>
|
||||
@@ -32,5 +32,6 @@
|
||||
<footer>
|
||||
EPI-Support-Monitor · liest <code>{{ zammad_url }}</code> · Seite aktualisiert sich automatisch
|
||||
</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<p class="warn">⚠ {{ intro }}</p>
|
||||
<div class="preview-box">{{ preview }}</div>
|
||||
|
||||
{% if attachments %}
|
||||
<div class="attach-summary">
|
||||
<strong>Anhänge ({{ attachments|length }}):</strong>
|
||||
{% for name in attachments %}<span class="attach-chip">📎 {{ name }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ action_url }}" class="confirm-actions"
|
||||
onsubmit="this.querySelector('button').disabled=true;">
|
||||
{% for name, value in fields.items() %}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Neues Ticket beim EPI-Support anlegen</h2></div>
|
||||
<form method="post" action="{{ url_for('new_ticket_preview') }}" class="form">
|
||||
<form method="post" action="{{ url_for('new_ticket_preview') }}"
|
||||
class="form attach-form" enctype="multipart/form-data">
|
||||
<label>Titel / Betreff
|
||||
<input type="text" name="title" required maxlength="200"
|
||||
placeholder="Kurze Zusammenfassung des Anliegens">
|
||||
@@ -20,11 +21,13 @@
|
||||
</label>
|
||||
<label>Nachricht
|
||||
<textarea name="body" rows="10" required
|
||||
placeholder="Beschreibe dein Anliegen…"></textarea>
|
||||
placeholder="Beschreibe dein Anliegen… (Screenshot mit Strg+V einfügen)"></textarea>
|
||||
</label>
|
||||
{% include "_attach.html" %}
|
||||
<div class="form-actions">
|
||||
<button class="btn-sm" type="submit">Weiter zur Bestätigung →</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static', filename='upload.js') }}"></script>{% endblock %}
|
||||
|
||||
+27
-3
@@ -25,6 +25,9 @@
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Konversation{% if conversation %} ({{ conversation|length }}){% endif %}</h2>
|
||||
<form method="post" action="{{ url_for('refresh_conversation', ticket_id=ticket.id) }}">
|
||||
<button class="btn-sm" type="submit">↻ Aktualisieren</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if conv_error %}
|
||||
<p class="muted">Konversation konnte nicht geladen werden: {{ conv_error }}</p>
|
||||
@@ -42,7 +45,25 @@
|
||||
{% if a.subject %}<div class="msg-subj">{{ a.subject }}</div>{% endif %}
|
||||
<div class="msg-body">{{ a.body }}</div>
|
||||
{% if a.attachments %}
|
||||
<div class="msg-att">📎 {{ a.attachments|join(', ') }}</div>
|
||||
{% set imgs = a.attachments|selectattr('is_image')|list %}
|
||||
{% set files = a.attachments|rejectattr('is_image')|list %}
|
||||
{% if imgs %}
|
||||
<div class="msg-imgs">
|
||||
{% for att in imgs %}
|
||||
<a href="{{ url_for('attachment', ticket_id=ticket.id, article_id=a.id, att_id=att.id) }}" target="_blank" rel="noopener">
|
||||
<img class="msg-img" loading="lazy" alt="{{ att.filename }}"
|
||||
src="{{ url_for('attachment', ticket_id=ticket.id, article_id=a.id, att_id=att.id) }}">
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if files %}
|
||||
<div class="msg-att">
|
||||
{% for att in files %}
|
||||
📎 {% if att.id %}<a href="{{ url_for('attachment', ticket_id=ticket.id, article_id=a.id, att_id=att.id) }}" target="_blank" rel="noopener">{{ att.filename }}</a>{% else %}{{ att.filename }}{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
@@ -52,9 +73,11 @@
|
||||
{% endif %}
|
||||
|
||||
{% if ticket.state != 'closed' %}
|
||||
<form method="post" action="{{ url_for('reply_preview', ticket_id=ticket.id) }}" class="reply-form">
|
||||
<form method="post" action="{{ url_for('reply_preview', ticket_id=ticket.id) }}"
|
||||
class="reply-form attach-form" enctype="multipart/form-data">
|
||||
<textarea name="body" rows="4" required
|
||||
placeholder="Antwort an den EPI-Support verfassen…"></textarea>
|
||||
placeholder="Antwort an den EPI-Support verfassen… (Screenshot mit Strg+V einfügen)"></textarea>
|
||||
{% include "_attach.html" %}
|
||||
<div class="form-actions">
|
||||
<span class="hint-inline">Geht nach Bestätigung live an EPI.</span>
|
||||
<button class="btn-sm" type="submit">Antworten →</button>
|
||||
@@ -84,3 +107,4 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static', filename='upload.js') }}"></script>{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user