Watch EPIRent version and archive its download packages

- Poll the public download page for "Aktuelle Version <nr> vom <date>",
  report changes through the normal change feed and notification mail,
  and show the current version as a card on the overview. Runs on its own
  interval (default 30 min); the first run only records a baseline.
- Archive epirentServer.zip / epirentClient.zip per version number so we
  keep a download cache and history: streamed to disk with SHA-256 and
  size, fetched in a background thread so ticket polling is unaffected,
  with a free-space check before downloading (~1.1 GB per version).
- Retention is configurable via EPIRENT_DOWNLOAD_KEEP, where 0 means
  unlimited; a successful archive is reported as a change and by mail.
- New Downloads page listing the archived versions with checksums and
  links to fetch the cached files.
- Notification lines now omit the arrow when there is no previous value
  and drop the "#" for entries without a ticket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:12:48 +02:00
co-authored by Claude Opus 4.8
parent 528d66b2b0
commit bdcb17285c
13 changed files with 575 additions and 10 deletions
+19
View File
@@ -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"))
+22
View File
@@ -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,
+256
View File
@@ -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")
+146
View File
@@ -0,0 +1,146 @@
"""Überwachung der EPIRent-Version auf der öffentlichen Download-Seite.
Die Seite (Squarespace) enthält im Klartext einen Block der Form:
<p>Aktuelle Version</p><p>14505 vom 30.06.2026</p>
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
+8 -3
View File
@@ -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)
+19 -1
View File
@@ -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)
+25 -3
View File
@@ -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/<int:ticket_id>")
def ticket_detail(ticket_id):
@@ -447,6 +449,26 @@ def create_app() -> Flask:
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@app.route("/downloads")
def downloads_view():
return render_template("downloads.html",
versions=downloads.history(),
human=downloads.human_size,
keep=config.EPIRENT_DOWNLOAD_KEEP,
current=epirent.current())
@app.route("/downloads/<version>/<path:filename>")
def download_file(version, filename):
# nur bekannte, in der DB verzeichnete Dateien ausliefern
entry = next((r for r in downloads.archived(version)
if r["filename"] == filename), None)
if not entry:
abort(404)
path = downloads.version_dir(version) / filename
if not path.exists():
abort(404)
return send_file(path, as_attachment=True, download_name=filename)
@app.route("/health")
def health():
with db.get_conn() as conn: