"""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]