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:
@@ -21,6 +21,21 @@ ZAMMAD_PASSWORD=
|
|||||||
# Intervall des Hintergrund-Abrufs in Sekunden
|
# Intervall des Hintergrund-Abrufs in Sekunden
|
||||||
POLL_INTERVAL_SECONDS=180
|
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 ----------------------------------------------------
|
# --- Web ----------------------------------------------------
|
||||||
# Pfad-Präfix hinter Apache (srhnweb/episupport)
|
# Pfad-Präfix hinter Apache (srhnweb/episupport)
|
||||||
EPISUPPORT_URL_PREFIX=/episupport
|
EPISUPPORT_URL_PREFIX=/episupport
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ ZAMMAD_PASSWORD = os.getenv("ZAMMAD_PASSWORD", "")
|
|||||||
# --- Abruf ---
|
# --- Abruf ---
|
||||||
POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "180"))
|
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 ---
|
# --- Web ---
|
||||||
URL_PREFIX = os.getenv("EPISUPPORT_URL_PREFIX", "/episupport").rstrip("/")
|
URL_PREFIX = os.getenv("EPISUPPORT_URL_PREFIX", "/episupport").rstrip("/")
|
||||||
PORT = int(os.getenv("EPISUPPORT_PORT", "8071"))
|
PORT = int(os.getenv("EPISUPPORT_PORT", "8071"))
|
||||||
|
|||||||
@@ -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 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 (
|
CREATE TABLE IF NOT EXISTS sync_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -22,18 +22,23 @@ def send_change_mail(changes: list[dict]) -> None:
|
|||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for c in changes:
|
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"):
|
if c.get("detail"):
|
||||||
line += f"\n {c['detail']}"
|
line += f"\n {c['detail']}"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
body = (
|
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)
|
+ "\n\n".join(lines)
|
||||||
+ f"\n\nDashboard: {config.ZAMMAD_URL}\n(Quelle: EPI-Support-Monitor)"
|
+ f"\n\nDashboard: {config.ZAMMAD_URL}\n(Quelle: EPI-Support-Monitor)"
|
||||||
)
|
)
|
||||||
|
|
||||||
msg = EmailMessage()
|
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["From"] = config.SMTP_FROM
|
||||||
msg["To"] = ", ".join(config.NOTIFY_TO)
|
msg["To"] = ", ".join(config.NOTIFY_TO)
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
|
|||||||
+19
-1
@@ -5,7 +5,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from . import config, conversation, db, notify
|
from . import config, conversation, db, downloads, epirent, notify
|
||||||
from .zammad import ZammadClient, ZammadError
|
from .zammad import ZammadClient, ZammadError
|
||||||
|
|
||||||
log = logging.getLogger("episupport.poller")
|
log = logging.getLogger("episupport.poller")
|
||||||
@@ -138,6 +138,18 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
|
|||||||
return found
|
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:
|
def sync_ticket(client: ZammadClient, ticket_id: int) -> None:
|
||||||
"""Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen.
|
"""Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen.
|
||||||
|
|
||||||
@@ -205,6 +217,12 @@ def poll_once(client: ZammadClient) -> int:
|
|||||||
all_changes.extend(ch)
|
all_changes.extend(ch)
|
||||||
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
|
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
|
||||||
_backfill_articles(client)
|
_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:
|
except ZammadError as e:
|
||||||
status = "error"
|
status = "error"
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|||||||
+25
-3
@@ -9,9 +9,10 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from flask import (Flask, Response, abort, flash, redirect, render_template,
|
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
|
from .zammad import ZammadClient, ZammadError
|
||||||
|
|
||||||
log = logging.getLogger("episupport.web")
|
log = logging.getLogger("episupport.web")
|
||||||
@@ -201,7 +202,8 @@ def create_app() -> Flask:
|
|||||||
stats=stats, recent=recent, last_run=last_run,
|
stats=stats, recent=recent, last_run=last_run,
|
||||||
active_state=state, sort=sort, dir=direction,
|
active_state=state, sort=sort, dir=direction,
|
||||||
unseen_map=unseen_map, show_archived=show_archived,
|
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>")
|
@app.route("/ticket/<int:ticket_id>")
|
||||||
def ticket_detail(ticket_id):
|
def ticket_detail(ticket_id):
|
||||||
@@ -447,6 +449,26 @@ def create_app() -> Flask:
|
|||||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
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")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
with db.get_conn() as conn:
|
with db.get_conn() as conn:
|
||||||
|
|||||||
+13
-1
@@ -33,7 +33,7 @@ footer { max-width: 1100px; margin: 2rem auto; padding: 1rem; color: var(--muted
|
|||||||
background: #e8f5ee; border: 1px solid #b7e0c9; border-radius: 6px;
|
background: #e8f5ee; border: 1px solid #b7e0c9; border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.4rem; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 1rem; margin-bottom: 1.4rem; }
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||||
padding: 1rem 1.2rem; box-shadow: var(--shadow);
|
padding: 1rem 1.2rem; box-shadow: var(--shadow);
|
||||||
@@ -106,6 +106,18 @@ footer { max-width: 1100px; margin: 2rem auto; padding: 1rem; color: var(--muted
|
|||||||
.tag-article { background: #e8f5ee; color: #0b6e4f; }
|
.tag-article { background: #e8f5ee; color: #0b6e4f; }
|
||||||
.tag-new_ticket { background: #f3e8fd; color: #6b27a8; }
|
.tag-new_ticket { background: #f3e8fd; color: #6b27a8; }
|
||||||
.tag-priority { background: #fff3d6; color: #8a6300; }
|
.tag-priority { background: #fff3d6; color: #8a6300; }
|
||||||
|
.tag-epirent_version { background: #e6f7f4; color: #0a6b62; }
|
||||||
|
.tag-epirent_download { background: #eef0fd; color: #3b3fa8; }
|
||||||
|
|
||||||
|
/* --- Download-Archiv --- */
|
||||||
|
.dl-version { padding: .8rem 1.1rem; border-bottom: 1px solid var(--line); }
|
||||||
|
.dl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.dl-ver { font-weight: 700; }
|
||||||
|
.dl-meta { color: var(--muted); font-size: .82rem; }
|
||||||
|
.dl-files { list-style: none; margin: .5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .3rem; }
|
||||||
|
.dl-files li { display: flex; align-items: center; gap: .7rem; font-size: .88rem; flex-wrap: wrap; }
|
||||||
|
.dl-size { color: var(--muted); font-size: .82rem; }
|
||||||
|
.dl-hash { color: #8a95a0; font-size: .75rem; }
|
||||||
|
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: .4rem; padding: 1rem 1.1rem; }
|
.chips { display: flex; flex-wrap: wrap; gap: .4rem; padding: 1rem 1.1rem; }
|
||||||
.chip { background: #eef2f5; padding: .25rem .6rem; border-radius: 999px; font-size: .82rem; }
|
.chip { background: #eef2f5; padding: .25rem .6rem; border-radius: 999px; font-size: .82rem; }
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
<li class="{{ 'unseen' if not c.seen else '' }}"
|
<li class="{{ 'unseen' if not c.seen else '' }}"
|
||||||
{% if c.detail %}title="{{ c.detail }}"{% endif %}>
|
{% if c.detail %}title="{{ c.detail }}"{% endif %}>
|
||||||
<span class="tag tag-{{ c.field }}">{{ c.label }}</span>
|
<span class="tag tag-{{ c.field }}">{{ c.label }}</span>
|
||||||
|
{% if c.ticket_id %}
|
||||||
<a class="cnum" href="{{ url_for('ticket_detail', ticket_id=c.ticket_id) }}">#{{ c.ticket_number }}</a>
|
<a class="cnum" href="{{ url_for('ticket_detail', ticket_id=c.ticket_id) }}">#{{ c.ticket_number }}</a>
|
||||||
|
{% endif %}
|
||||||
<span class="ctitle">{{ c.ticket_title }}</span>
|
<span class="ctitle">{{ c.ticket_title }}</span>
|
||||||
<span class="cchg">{{ c.old_value or '–' }} → <b>{{ c.new_value or '–' }}</b></span>
|
<span class="cchg">{% if c.old_value %}{{ c.old_value }} → {% endif %}<b>{{ c.new_value or '–' }}</b></span>
|
||||||
<time>{{ c.detected_at|dt }}</time>
|
<time>{{ c.detected_at|dt }}</time>
|
||||||
{% if not c.seen %}
|
{% if not c.seen %}
|
||||||
<form method="post" action="{{ url_for('mark_one_read', change_id=c.id) }}" class="mark-one">
|
<form method="post" action="{{ url_for('mark_one_read', change_id=c.id) }}" class="mark-one">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<a href="{{ url_for('index') }}">Übersicht</a>
|
<a href="{{ url_for('index') }}">Übersicht</a>
|
||||||
<a href="{{ url_for('analytics_view') }}">Auswertung</a>
|
<a href="{{ url_for('analytics_view') }}">Auswertung</a>
|
||||||
<a href="{{ url_for('changes') }}">Änderungen</a>
|
<a href="{{ url_for('changes') }}">Änderungen</a>
|
||||||
|
<a href="{{ url_for('downloads_view') }}">Downloads</a>
|
||||||
<a href="{{ url_for('new_ticket_form') }}">+ Neues Ticket</a>
|
<a href="{{ url_for('new_ticket_form') }}">+ Neues Ticket</a>
|
||||||
<a href="{{ zammad_url }}" target="_blank" rel="noopener">Zammad ↗</a>
|
<a href="{{ zammad_url }}" target="_blank" rel="noopener">Zammad ↗</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Downloads · EPI-Support{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>EPIRent Download-Archiv</h2>
|
||||||
|
{% if current %}
|
||||||
|
<span class="hint-inline">aktuelle Version: <b>{{ current.version }}</b> vom {{ current.released_at }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if versions %}
|
||||||
|
{% for v in versions %}
|
||||||
|
<div class="dl-version">
|
||||||
|
<div class="dl-head">
|
||||||
|
<span class="dl-ver">Version {{ v.version }}</span>
|
||||||
|
<span class="dl-meta">{{ human(v.total) }} · gesichert am {{ v.downloaded_at|dt }}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="dl-files">
|
||||||
|
{% for f in v.files %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('download_file', version=v.version, filename=f.filename) }}">⬇ {{ f.filename }}</a>
|
||||||
|
<span class="dl-size">{{ human(f.size) }}</span>
|
||||||
|
{% if f.sha256 %}<code class="dl-hash" title="SHA-256">{{ f.sha256[:16] }}…</code>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if keep and keep > 0 %}
|
||||||
|
<p class="hint">Es werden die letzten <b>{{ keep }}</b> Versionsstände aufbewahrt; ältere werden automatisch gelöscht.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">Aufbewahrung: <b>unbegrenzt</b> – es wird nichts automatisch gelöscht (ca. 1,1 GB je Stand).</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Noch keine Pakete archiviert. Der Poller lädt sie im Hintergrund,
|
||||||
|
sobald eine Version bekannt ist (ca. 1,1 GB pro Stand – das dauert einen Moment).</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,6 +9,12 @@
|
|||||||
<div class="card {{ 'alert' if stats.unseen else '' }}">
|
<div class="card {{ 'alert' if stats.unseen else '' }}">
|
||||||
<span class="num">{{ stats.unseen }}</span><span class="lbl">ungelesene Änderungen</span>
|
<span class="num">{{ stats.unseen }}</span><span class="lbl">ungelesene Änderungen</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if epirent_version %}
|
||||||
|
<div class="card">
|
||||||
|
<span class="num">{{ epirent_version.version }}</span>
|
||||||
|
<span class="lbl">EPIRent-Version<br>vom {{ epirent_version.released_at }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card status">
|
<div class="card status">
|
||||||
{% if last_run %}
|
{% if last_run %}
|
||||||
<span class="num {{ 'ok' if last_run.status == 'ok' else 'err' }}">
|
<span class="num {{ 'ok' if last_run.status == 'ok' else 'err' }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user