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
|
||||
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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
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
@@ -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
@@ -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:
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
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-new_ticket { background: #f3e8fd; color: #6b27a8; }
|
||||
.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; }
|
||||
.chip { background: #eef2f5; padding: .25rem .6rem; border-radius: 999px; font-size: .82rem; }
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<li class="{{ 'unseen' if not c.seen else '' }}"
|
||||
{% if c.detail %}title="{{ c.detail }}"{% endif %}>
|
||||
<span class="tag tag-{{ c.field }}">{{ c.label }}</span>
|
||||
<a class="cnum" href="{{ url_for('ticket_detail', ticket_id=c.ticket_id) }}">#{{ c.ticket_number }}</a>
|
||||
{% if c.ticket_id %}
|
||||
<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="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>
|
||||
{% if not c.seen %}
|
||||
<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('analytics_view') }}">Auswertung</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="{{ zammad_url }}" target="_blank" rel="noopener">Zammad ↗</a>
|
||||
</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 '' }}">
|
||||
<span class="num">{{ stats.unseen }}</span><span class="lbl">ungelesene Änderungen</span>
|
||||
</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">
|
||||
{% if last_run %}
|
||||
<span class="num {{ 'ok' if last_run.status == 'ok' else 'err' }}">
|
||||
|
||||
Reference in New Issue
Block a user