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
+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")