Add "truly done" ticket archiving with auto-unhide

- Let the user mark closed tickets as really done and hide them, so
  tickets closed prematurely by an agent stay visible until confirmed
- User-side "archived" flag stored in the DB (independent of Zammad),
  added via a schema migration on existing databases
- Hide archived tickets from the overview by default with an
  "erledigte einblenden" toggle; archive/unarchive from the ticket
  detail page and via a per-row quick action
- Poller automatically un-hides an archived ticket when new activity is
  detected, so nothing is missed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:34:09 +02:00
co-authored by Claude Opus 4.8
parent 1107a912bc
commit 528d66b2b0
6 changed files with 94 additions and 15 deletions
+10 -1
View File
@@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS tickets (
last_close_at TEXT,
raw_json TEXT,
first_seen_at TEXT,
last_synced_at TEXT
last_synced_at TEXT,
archived INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS changes (
@@ -103,3 +104,11 @@ def get_conn():
def init_db() -> None:
with get_conn() as conn:
conn.executescript(SCHEMA)
_migrate(conn)
def _migrate(conn) -> None:
"""Nachträgliche Schema-Anpassungen für bestehende Datenbanken."""
cols = {r["name"] for r in conn.execute("PRAGMA table_info(tickets)")}
if "archived" not in cols:
conn.execute("ALTER TABLE tickets ADD COLUMN archived INTEGER NOT NULL DEFAULT 0")
+6 -1
View File
@@ -196,8 +196,13 @@ def poll_once(client: ZammadClient) -> int:
except ZammadError:
pass
else:
all_changes.extend(_detect_changes(conn, client, t, existing))
ch = _detect_changes(conn, client, t, existing)
_upsert(conn, row, is_new=False)
if ch:
# Neue Aktivität -> ggf. abgelegtes Ticket wieder einblenden
conn.execute(
"UPDATE tickets SET archived=0 WHERE id=?", (t["id"],))
all_changes.extend(ch)
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
_backfill_articles(client)
except ZammadError as e:
+31 -4
View File
@@ -145,15 +145,20 @@ def create_app() -> Flask:
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 = []
where = ""
conds = []
if state == "not_closed":
where = "WHERE state NOT IN ('closed','merged','removed')"
conds.append("state NOT IN ('closed','merged','removed')")
elif state:
where = "WHERE 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 = {
@@ -189,10 +194,14 @@ def create_app() -> Flask:
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)
unseen_map=unseen_map, show_archived=show_archived,
archived_count=archived_count)
@app.route("/ticket/<int:ticket_id>")
def ticket_detail(ticket_id):
@@ -236,6 +245,24 @@ def create_app() -> Flask:
flash("✗ Status konnte nicht gesetzt werden: " + str(e))
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@app.route("/ticket/<int:ticket_id>/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/<int:ticket_id>/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/<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)