diff --git a/.env.example b/.env.example index 05d537e..4377e08 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,21 @@ ZAMMAD_PASSWORD= # Intervall des Hintergrund-Abrufs in Sekunden POLL_INTERVAL_SECONDS=180 +# --- EPIRent-Versionsüberwachung ---------------------------- +# Liest "Aktuelle Version / NNNNN vom TT.MM.JJJJ" von der Download-Seite und +# meldet Änderungen wie jede andere Änderung (Feed + E-Mail). +EPIRENT_CHECK_ENABLED=true +EPIRENT_URL=https://www.epi.rent/downloads +EPIRENT_CHECK_INTERVAL_SECONDS=1800 + +# Download-Archiv: sichert die Pakete je Versionsnummer (Verlauf/Cache). +# ACHTUNG: ca. 1,1 GB pro Versionsstand (Server ~923 MB + Client ~218 MB). +EPIRENT_DOWNLOAD_ENABLED=true +EPIRENT_DOWNLOAD_URLS=https://download.epi.rent/latest/epirentServer.zip,https://download.epi.rent/latest/epirentClient.zip +# Wie viele Versionsstände aufbewahrt werden (3 => ca. 3,4 GB). +# 0 = unbegrenzt (nichts wird automatisch gelöscht – Plattenplatz im Auge behalten!) +EPIRENT_DOWNLOAD_KEEP=3 + # --- Web ---------------------------------------------------- # Pfad-Präfix hinter Apache (srhnweb/episupport) EPISUPPORT_URL_PREFIX=/episupport diff --git a/app/config.py b/app/config.py index 910e99a..81b23e8 100644 --- a/app/config.py +++ b/app/config.py @@ -28,6 +28,25 @@ ZAMMAD_PASSWORD = os.getenv("ZAMMAD_PASSWORD", "") # --- Abruf --- POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "180")) +# --- EPIRent-Versionsüberwachung (Download-Seite) --- +EPIRENT_CHECK_ENABLED = _b(os.getenv("EPIRENT_CHECK_ENABLED"), True) +EPIRENT_URL = os.getenv("EPIRENT_URL", "https://www.epi.rent/downloads") +EPIRENT_CHECK_INTERVAL_SECONDS = int( + os.getenv("EPIRENT_CHECK_INTERVAL_SECONDS", "1800")) + +# --- Download-Archiv (pro Version zwischengespeichert) --- +EPIRENT_DOWNLOAD_ENABLED = _b(os.getenv("EPIRENT_DOWNLOAD_ENABLED"), True) +EPIRENT_DOWNLOAD_URLS = [ + u.strip() for u in os.getenv( + "EPIRENT_DOWNLOAD_URLS", + "https://download.epi.rent/latest/epirentServer.zip," + "https://download.epi.rent/latest/epirentClient.zip" + ).split(",") if u.strip() +] +# Wie viele Versionsstände aufbewahrt werden (je ~1,1 GB!). +# 0 = unbegrenzt aufbewahren (es wird nichts automatisch gelöscht). +EPIRENT_DOWNLOAD_KEEP = int(os.getenv("EPIRENT_DOWNLOAD_KEEP", "3")) + # --- Web --- URL_PREFIX = os.getenv("EPISUPPORT_URL_PREFIX", "/episupport").rstrip("/") PORT = int(os.getenv("EPISUPPORT_PORT", "8071")) diff --git a/app/db.py b/app/db.py index d80bfa4..8a90fbd 100644 --- a/app/db.py +++ b/app/db.py @@ -70,6 +70,28 @@ CREATE TABLE IF NOT EXISTS articles ( CREATE INDEX IF NOT EXISTS idx_articles_ticket ON articles(ticket_id, created_at); +CREATE TABLE IF NOT EXISTS versions ( + source TEXT PRIMARY KEY, + version TEXT, + released_at TEXT, + checked_at TEXT, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL, + filename TEXT NOT NULL, + url TEXT, + size INTEGER, + sha256 TEXT, + remote_modified TEXT, + downloaded_at TEXT, + UNIQUE(version, filename) +); + +CREATE INDEX IF NOT EXISTS idx_downloads_version ON downloads(version); + CREATE TABLE IF NOT EXISTS sync_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT, diff --git a/app/downloads.py b/app/downloads.py new file mode 100644 index 0000000..cddd023 --- /dev/null +++ b/app/downloads.py @@ -0,0 +1,256 @@ +"""Download-Archiv: sichert die EPIRent-Pakete je Versionsnummer. + +Die Dateien sind groß (Server ~923 MB, Client ~218 MB, zusammen ~1,1 GB pro +Version). Deshalb: + * Download läuft im Hintergrund-Thread (Ticketabruf blockiert nicht), + * gestreamt auf Platte (kein Speicherverbrauch), + * Platzprüfung vorab, + * Aufbewahrung begrenzt (EPIRENT_DOWNLOAD_KEEP, Standard 3 Stände). +""" +import hashlib +import logging +import shutil +import threading +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse + +import requests + +from . import config, db, notify + +log = logging.getLogger("episupport.downloads") + +DOWNLOAD_DIR = config.DATA_DIR / "downloads" +_lock = threading.Lock() +_running = False + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def filename_for(url: str) -> str: + name = Path(urlparse(url).path).name + return name or "download.bin" + + +def version_dir(version: str) -> Path: + # Versionsnummern sind rein numerisch – zur Sicherheit trotzdem säubern + safe = "".join(ch for ch in str(version) if ch.isalnum() or ch in "._-") + return DOWNLOAD_DIR / (safe or "unbekannt") + + +def archived(version: str) -> list[dict]: + with db.get_conn() as conn: + rows = conn.execute( + "SELECT * FROM downloads WHERE version=? ORDER BY filename", + (str(version),)).fetchall() + return [dict(r) for r in rows] + + +def is_complete(version: str) -> bool: + """Sind alle konfigurierten Dateien für diese Version vorhanden?""" + want = {filename_for(u) for u in config.EPIRENT_DOWNLOAD_URLS} + have = {r["filename"] for r in archived(version) + if (version_dir(version) / r["filename"]).exists()} + return bool(want) and want.issubset(have) + + +def history() -> list[dict]: + """Alle archivierten Versionen mit Dateien und Gesamtgröße.""" + with db.get_conn() as conn: + rows = conn.execute( + "SELECT * FROM downloads ORDER BY downloaded_at DESC, filename" + ).fetchall() + by_version: dict[str, dict] = {} + for r in rows: + v = by_version.setdefault(r["version"], { + "version": r["version"], "files": [], "total": 0, + "downloaded_at": r["downloaded_at"]}) + v["files"].append(dict(r)) + v["total"] += r["size"] or 0 + return list(by_version.values()) + + +def human_size(n) -> str: + if not n: + return "–" + n = float(n) + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}".replace(".", ",") + n /= 1024 + return f"{n:.1f} GB" + + +# ------------------------------------------------------------------ Download +def _remote_info(url: str) -> tuple[int, str | None]: + """(Größe, Last-Modified) per HEAD ermitteln.""" + r = requests.head(url, timeout=30, allow_redirects=True, + headers={"User-Agent": "episupport-monitor/1.0"}) + r.raise_for_status() + return int(r.headers.get("Content-Length") or 0), r.headers.get("Last-Modified") + + +def _download(url: str, dest: Path) -> tuple[int, str]: + """Streamend herunterladen; gibt (Größe, sha256) zurück.""" + tmp = dest.with_suffix(dest.suffix + ".part") + h = hashlib.sha256() + size = 0 + with requests.get(url, stream=True, timeout=(30, 300), + headers={"User-Agent": "episupport-monitor/1.0"}) as r: + r.raise_for_status() + with open(tmp, "wb") as f: + for chunk in r.iter_content(chunk_size=1024 * 1024): + if not chunk: + continue + f.write(chunk) + h.update(chunk) + size += len(chunk) + tmp.replace(dest) + return size, h.hexdigest() + + +def _enough_space(needed: int) -> bool: + DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + free = shutil.disk_usage(DOWNLOAD_DIR).free + if free < needed * 1.2: + log.error("Zu wenig Speicherplatz: benötigt ~%s, frei %s.", + human_size(needed), human_size(free)) + return False + return True + + +def fetch_version(version: str) -> list[dict]: + """Fehlende Dateien dieser Version holen. Gibt die neu geholten zurück.""" + urls = config.EPIRENT_DOWNLOAD_URLS + if not urls: + return [] + target = version_dir(version) + target.mkdir(parents=True, exist_ok=True) + + todo = [] + for url in urls: + name = filename_for(url) + if (target / name).exists() and any( + r["filename"] == name for r in archived(version)): + continue + todo.append((url, name)) + if not todo: + return [] + + # Platzbedarf vorab prüfen + needed = 0 + infos = {} + for url, name in todo: + try: + size, modified = _remote_info(url) + except requests.RequestException as e: + log.warning("HEAD fehlgeschlagen für %s: %s", url, e) + size, modified = 0, None + infos[name] = (size, modified) + needed += size + if needed and not _enough_space(needed): + return [] + + got = [] + for url, name in todo: + dest = target / name + try: + log.info("Lade %s (%s) für Version %s …", name, + human_size(infos[name][0]), version) + size, digest = _download(url, dest) + with db.get_conn() as conn: + conn.execute( + "INSERT OR REPLACE INTO downloads (version, filename, url, " + "size, sha256, remote_modified, downloaded_at) " + "VALUES (?,?,?,?,?,?,?)", + (str(version), name, url, size, digest, + infos[name][1], _now())) + got.append({"filename": name, "size": size, "sha256": digest}) + log.info("Fertig: %s (%s)", name, human_size(size)) + except (requests.RequestException, OSError) as e: + log.error("Download %s fehlgeschlagen: %s", url, e) + try: + (target / (name + ".part")).unlink(missing_ok=True) + except OSError: + pass + return got + + +def cleanup(keep: int | None = None) -> list[str]: + """Alte Versionsstände löschen, nur die neuesten `keep` behalten. + + `keep = 0` (oder negativ) bedeutet: unbegrenzt aufbewahren – es wird + nichts gelöscht. + """ + keep = config.EPIRENT_DOWNLOAD_KEEP if keep is None else keep + if keep <= 0: + return [] + versions = [v["version"] for v in history()] + removed = [] + for v in versions[keep:]: + d = version_dir(v) + shutil.rmtree(d, ignore_errors=True) + with db.get_conn() as conn: + conn.execute("DELETE FROM downloads WHERE version=?", (v,)) + removed.append(v) + log.info("Alten Download-Stand %s entfernt.", v) + return removed + + +# --------------------------------------------------------------- Hintergrund +def archive_async(version: str) -> None: + """Archivierung im Hintergrund starten (max. eine gleichzeitig).""" + global _running + if not config.EPIRENT_DOWNLOAD_ENABLED or not version: + return + with _lock: + if _running: + log.info("Archivierung läuft bereits – überspringe.") + return + _running = True + + def _work(): + global _running + try: + got = fetch_version(version) + removed = cleanup() + if got: + _report(version, got, removed) + except Exception: # noqa: BLE001 + log.exception("Archivierung fehlgeschlagen") + finally: + with _lock: + _running = False + + threading.Thread(target=_work, name="epirent-archive", daemon=True).start() + + +def _report(version: str, got: list[dict], removed: list[str]) -> None: + """Erfolgreiche Archivierung als Änderung erfassen und mailen.""" + parts = [f"{g['filename']} ({human_size(g['size'])})" for g in got] + detail = ", ".join(parts) + if removed: + detail += f" · alte Stände entfernt: {', '.join(removed)}" + change = { + "ticket_number": None, + "ticket_title": "EPIRent Download-Archiv", + "field": "epirent_download", + "label": "Downloads gesichert", + "old_value": None, + "new_value": f"Version {version}", + "detail": detail, + } + with db.get_conn() as conn: + conn.execute( + "INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, " + "label, old_value, new_value, detail, detected_at) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (0, None, change["ticket_title"], change["field"], change["label"], + None, change["new_value"], detail, _now())) + try: + notify.send_change_mail([change]) + except Exception: # noqa: BLE001 + log.exception("Mail zur Archivierung fehlgeschlagen") diff --git a/app/epirent.py b/app/epirent.py new file mode 100644 index 0000000..52f898b --- /dev/null +++ b/app/epirent.py @@ -0,0 +1,146 @@ +"""Überwachung der EPIRent-Version auf der öffentlichen Download-Seite. + +Die Seite (Squarespace) enthält im Klartext einen Block der Form: + +
Aktuelle Version
14505 vom 30.06.2026
+ +Wir lesen Version + Datum aus, vergleichen mit dem zuletzt gesehenen Stand und +melden eine Änderung wie jede andere (Feed + E-Mail). +""" +import logging +import re +from datetime import datetime, timezone + +import requests + +from . import config, db + +log = logging.getLogger("episupport.epirent") + +SOURCE = "epirent" +# "14505 vom 30.06.2026" +_PATTERN = re.compile(r"(\d{4,6})\s*vom\s*(\d{1,2}\.\d{1,2}\.\d{4})", re.I) +_TAGS = re.compile(r"<[^>]+>") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def parse_version(html: str) -> tuple[str, str] | None: + """(Version, Datum) aus dem Seiten-HTML lesen.""" + if not html: + return None + # Bevorzugt direkt nach der Überschrift "Aktuelle Version" suchen + idx = html.find("Aktuelle Version") + if idx != -1: + segment = " ".join(_TAGS.sub(" ", html[idx:idx + 600]).split()) + m = _PATTERN.search(segment) + if m: + return m.group(1), m.group(2) + # Rückfall: im gesamten Dokument suchen + m = _PATTERN.search(_TAGS.sub(" ", html)) + return (m.group(1), m.group(2)) if m else None + + +def fetch_version(timeout: int = 30) -> tuple[str, str] | None: + r = requests.get( + config.EPIRENT_URL, + timeout=timeout, + headers={"User-Agent": "episupport-monitor/1.0"}, + ) + r.raise_for_status() + return parse_version(r.text) + + +def _due(conn) -> bool: + """Ist der nächste Abruf fällig (Intervall abgelaufen)?""" + row = conn.execute( + "SELECT checked_at FROM versions WHERE source=?", (SOURCE,) + ).fetchone() + if not row or not row["checked_at"]: + return True + try: + last = datetime.fromisoformat(row["checked_at"]) + except ValueError: + return True + age = (datetime.now(timezone.utc) - last).total_seconds() + return age >= config.EPIRENT_CHECK_INTERVAL_SECONDS + + +def check(force: bool = False) -> dict | None: + """Version prüfen. Gibt bei Änderung ein Change-Dict zurück, sonst None. + + Muss AUSSERHALB einer offenen Poller-Transaktion laufen (eigene Verbindung). + """ + if not config.EPIRENT_CHECK_ENABLED: + return None + with db.get_conn() as conn: + if not force and not _due(conn): + return None + previous = conn.execute( + "SELECT * FROM versions WHERE source=?", (SOURCE,) + ).fetchone() + + try: + found = fetch_version() + except requests.RequestException as e: + log.warning("EPIRent-Seite nicht abrufbar: %s", e) + return None + if not found: + log.warning("Version auf %s nicht gefunden (Seitenaufbau geändert?).", + config.EPIRENT_URL) + return None + + version, released = found + now = _now() + + # Erster Lauf: nur merken, nicht melden + if previous is None: + with db.get_conn() as conn: + conn.execute( + "INSERT INTO versions (source, version, released_at, checked_at, " + "updated_at) VALUES (?,?,?,?,?)", + (SOURCE, version, released, now, now)) + log.info("EPIRent-Version erstmals erfasst: %s vom %s", version, released) + return None + + unchanged = (previous["version"] == version + and previous["released_at"] == released) + if unchanged: + with db.get_conn() as conn: + conn.execute("UPDATE versions SET checked_at=? WHERE source=?", + (now, SOURCE)) + return None + + # --- Änderung erkannt --- + old_txt = f"{previous['version']} vom {previous['released_at']}" + new_txt = f"{version} vom {released}" + log.info("Neue EPIRent-Version: %s -> %s", old_txt, new_txt) + with db.get_conn() as conn: + conn.execute( + "UPDATE versions SET version=?, released_at=?, checked_at=?, " + "updated_at=? WHERE source=?", + (version, released, now, now, SOURCE)) + conn.execute( + "INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, " + "label, old_value, new_value, detail, detected_at) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (0, None, "EPIRent Download-Seite", "epirent_version", + "Neue EPIRent-Version", old_txt, new_txt, config.EPIRENT_URL, now)) + return { + "ticket_number": None, + "ticket_title": "EPIRent Download-Seite", + "field": "epirent_version", + "label": "Neue EPIRent-Version", + "old_value": old_txt, + "new_value": new_txt, + "detail": config.EPIRENT_URL, + } + + +def current() -> dict | None: + with db.get_conn() as conn: + row = conn.execute( + "SELECT * FROM versions WHERE source=?", (SOURCE,)).fetchone() + return dict(row) if row else None diff --git a/app/notify.py b/app/notify.py index 766d8ab..be457a2 100644 --- a/app/notify.py +++ b/app/notify.py @@ -22,18 +22,23 @@ def send_change_mail(changes: list[dict]) -> None: lines = [] for c in changes: - line = f"#{c['ticket_number']} {c['ticket_title']}\n {c['label']}: {c.get('old_value') or '–'} -> {c.get('new_value') or '–'}" + # Einträge ohne Ticketbezug (z. B. EPIRent-Version) ohne "#" ausgeben + head = f"#{c['ticket_number']} " if c.get("ticket_number") else "" + new = c.get("new_value") or "–" + # ohne Vorwert (z. B. neues Ticket, Download-Archiv) keinen Pfeil setzen + value = f"{c['old_value']} -> {new}" if c.get("old_value") else new + line = f"{head}{c['ticket_title']}\n {c['label']}: {value}" if c.get("detail"): line += f"\n {c['detail']}" lines.append(line) body = ( - f"Es gibt {len(changes)} neue Änderung(en) an euren EPI-Support-Tickets:\n\n" + f"Es gibt {len(changes)} neue Änderung(en):\n\n" + "\n\n".join(lines) + f"\n\nDashboard: {config.ZAMMAD_URL}\n(Quelle: EPI-Support-Monitor)" ) msg = EmailMessage() - msg["Subject"] = f"[EPI-Support] {len(changes)} Ticket-Änderung(en)" + msg["Subject"] = f"[EPI-Support] {len(changes)} Änderung(en)" msg["From"] = config.SMTP_FROM msg["To"] = ", ".join(config.NOTIFY_TO) msg.set_content(body) diff --git a/app/poller.py b/app/poller.py index 267e8cb..cee71cc 100644 --- a/app/poller.py +++ b/app/poller.py @@ -5,7 +5,7 @@ import logging import time from datetime import datetime, timezone -from . import config, conversation, db, notify +from . import config, conversation, db, downloads, epirent, notify from .zammad import ZammadClient, ZammadError log = logging.getLogger("episupport.poller") @@ -138,6 +138,18 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]: return found +def _archive_downloads() -> None: + """Download-Pakete der aktuellen EPIRent-Version sichern (im Hintergrund).""" + if not config.EPIRENT_DOWNLOAD_ENABLED: + return + cur = epirent.current() + if not cur or not cur.get("version"): + return + if downloads.is_complete(cur["version"]): + return + downloads.archive_async(cur["version"]) + + def sync_ticket(client: ZammadClient, ticket_id: int) -> None: """Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen. @@ -205,6 +217,12 @@ def poll_once(client: ZammadClient) -> int: all_changes.extend(ch) # Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen) _backfill_articles(client) + # EPIRent-Version auf der Download-Seite prüfen (eigenes Intervall) + version_change = epirent.check() + if version_change: + all_changes.append(version_change) + # Pakete dieser Version im Hintergrund archivieren, falls noch nicht da + _archive_downloads() except ZammadError as e: status = "error" error = str(e) diff --git a/app/web.py b/app/web.py index 6d75dd0..11e56ae 100644 --- a/app/web.py +++ b/app/web.py @@ -9,9 +9,10 @@ from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from flask import (Flask, Response, abort, flash, redirect, render_template, - request, url_for) + request, send_file, url_for) -from . import analytics, charts, config, conversation, db, export, poller, uploads +from . import (analytics, charts, config, conversation, db, downloads, epirent, + export, poller, uploads) from .zammad import ZammadClient, ZammadError log = logging.getLogger("episupport.web") @@ -201,7 +202,8 @@ def create_app() -> Flask: 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) + archived_count=archived_count, + epirent_version=epirent.current()) @app.route("/ticket/