From cb059ac20d8fc6941accb4ab8caaa869d37228dc Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 3 Nov 2026 12:51:02 +0100 Subject: [PATCH] =?UTF-8?q?Gef=C3=BChrtes=20Update:=20"Anwendung=20ersetze?= =?UTF-8?q?n"=20im=20Update-Hinweis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Update-Hinweis bietet neben "Update Ordner öffnen" nun den Button "Anwendung ersetzen". Er kopiert das ZIP vom SMB-Laufwerk lokal, prüft es, entpackt es in einen eigenen Ordner neben der aktuellen Installation, übernimmt input/, output/ und eigene Templates und startet die neue EXE. Die alte Installation bleibt als Rückfallebene erhalten; eine angepasste settings.ini wird als settings.vorherige-version.ini aufbewahrt. Co-Authored-By: Claude Opus 5.5 --- 0_application/CHANGELOG.md | 8 ++ 0_application/README.md | 14 ++ 0_application/app.py | 37 ++++- 0_application/tests/test_update_installer.py | 121 ++++++++++++++++ 0_application/update_installer.py | 137 +++++++++++++++++++ 1_build-pipeline/.last_version | 2 +- 1_build-pipeline/build.spec | 1 + 7 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 0_application/tests/test_update_installer.py create mode 100644 0_application/update_installer.py diff --git a/0_application/CHANGELOG.md b/0_application/CHANGELOG.md index c09eeeb..44abd3f 100644 --- a/0_application/CHANGELOG.md +++ b/0_application/CHANGELOG.md @@ -5,6 +5,14 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). +--- + +## [1.4] – 2026-11-03 + +### Hinzugefügt + +- automatische Update Funktion +--- ## [1.3.4] – 2026-11-03 diff --git a/0_application/README.md b/0_application/README.md index 6396bbe..e445308 100644 --- a/0_application/README.md +++ b/0_application/README.md @@ -25,6 +25,20 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe` 5. **"Tätigkeitsnachweise generieren"** klicken - Die generierten Dateien werden im `output/`-Ordner abgelegt +## Update + +Liegt eine neuere Version auf dem Laufwerk J:, erscheint oben rechts ein Hinweis. + +- **„Anwendung ersetzen“** richtet die neue Version in einem eigenen Ordner neben der + aktuellen ein (z. B. `Tätigkeitsnachweis-Generator v1.4.0`), übernimmt `input/`, + `output/` und eigene Template-Dateien und startet sie. Das folgende Rückfragefenster + mit „Ja“ bestätigen, dann wird die alte Version beendet. +- Die alte Version bleibt als Ordner erhalten und kann gelöscht werden, sobald die neue läuft. +- Wurde `settings.ini` angepasst, liegt die bisherige Datei als + `config/settings.vorherige-version.ini` daneben. +- Desktop-Verknüpfungen zeigen weiterhin auf den alten Ordner und müssen neu angelegt werden. +- **„Update Ordner öffnen“** öffnet das Verzeichnis auf J: für ein manuelles Update. + ## Konfiguration Alle Einstellungen liegen im `config/`-Ordner: diff --git a/0_application/app.py b/0_application/app.py index c07945c..dedf66d 100644 --- a/0_application/app.py +++ b/0_application/app.py @@ -30,6 +30,7 @@ from src.parser import parse_input_file, group_by_employee, get_unique_antragsnu from src.validator import validate_input_file, validate_template from src.writer import generate_activity_reports from version_check import UPDATE_DIR, get_latest_version +from update_installer import can_replace_application, install_update, start_application def open_input_folder(directory: Path): @@ -91,7 +92,7 @@ try: local_version, latest_version = get_latest_version() current_version_text = f"v{'.'.join(map(str, local_version))}" if latest_version: - available_version, _ = latest_version + available_version, update_path = latest_version version_text = ".".join(map(str, available_version)) st.html( f''' @@ -136,6 +138,33 @@ try: if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"): if sys.platform == "win32": os.startfile(str(UPDATE_DIR)) + replace_supported = can_replace_application() + replace_clicked = st.button( + "Anwendung ersetzen 🔄", key="replace_application", + disabled=not replace_supported or st.session_state.get("update_started", False), + help=("Neue Version neben der aktuellen einrichten, input/ und output/ übernehmen " + "und starten. Die aktuelle Version bleibt als Ordner erhalten." + if replace_supported else "Nur in der installierten Windows-Anwendung verfügbar.")) + if replace_clicked: + try: + with st.spinner(f"Version v{version_text} wird eingerichtet …"): + result = install_update(update_path, PROJECT_ROOT) + start_application(result.target_dir) + except Exception as exc: # Der umgebende Block verschluckt sonst jeden Fehler. + st.error(f"Update fehlgeschlagen: {exc}\n\n" + "Alternativ das Update manuell aus dem Update-Ordner einrichten.") + else: + st.session_state.update_started = True + details = [f"Neuer Programmordner: `{result.target_dir}`"] + if result.already_installed: + details.append("Die Version war dort bereits eingerichtet und wurde nur gestartet.") + else: + details.append("Die Ordner input/ und output/ wurden übernommen.") + if result.previous_settings: + details.append(f"Die bisherige settings.ini wurde als `{result.previous_settings.name}` aufbewahrt.") + details.append("Bitte im folgenden Fenster bestätigen, dass die alte Version beendet wird. " + "Desktop-Verknüpfungen zeigen noch auf den alten Ordner.") + st.success(f"Version v{version_text} wird gestartet.\n\n" + "\n\n".join(details)) except Exception: pass diff --git a/0_application/tests/test_update_installer.py b/0_application/tests/test_update_installer.py new file mode 100644 index 0000000..179afc7 --- /dev/null +++ b/0_application/tests/test_update_installer.py @@ -0,0 +1,121 @@ +"""Tests für das Einrichten einer neuen Version neben der aktuellen Installation.""" + +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import zipfile +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import update_installer +from update_installer import EXE_NAME, UpdateError, install_update + + +class InstallUpdateTests(unittest.TestCase): + def setUp(self): + self._temporary = tempfile.TemporaryDirectory() + self.root = Path(self._temporary.name).resolve() + self.current = self.root / "Tätigkeitsnachweis-Generator v1.3.3" + for folder in ["input", "output/2026", "config", "template"]: + (self.current / folder).mkdir(parents=True) + (self.current / EXE_NAME).write_bytes(b"old") + (self.current / "input" / "daten.xlsx").write_bytes(b"input") + (self.current / "output" / "2026" / "nachweis.pdf").write_bytes(b"output") + (self.current / "config" / "settings.ini").write_bytes(b"[a]\nx=1\n") + (self.current / "template" / "vorlage.pdf").write_bytes(b"old template") + (self.current / "template" / "eigene.pdf").write_bytes(b"own template") + self.share = self.root / "share" + self.share.mkdir() + + def tearDown(self): + self._temporary.cleanup() + + def make_zip(self, files=None, version="1.4.0"): + files = files if files is not None else { + EXE_NAME: b"new", + "config/settings.ini": b"[a]\nx=1\n", + "template/vorlage.pdf": b"new template", + "input/": b"", "output/": b"", + } + path = self.share / f"Tätigkeitsnachweis-Generator v{version}.zip" + with zipfile.ZipFile(path, "w") as package: + for name, content in files.items(): + package.writestr(name, content) + return path + + def test_installs_next_to_current_and_copies_user_data(self): + result = install_update(self.make_zip(), self.current) + target = self.root / "Tätigkeitsnachweis-Generator v1.4.0" + self.assertEqual(result.target_dir, target) + self.assertFalse(result.already_installed) + self.assertIsNone(result.previous_settings) + self.assertEqual((target / EXE_NAME).read_bytes(), b"new") + self.assertEqual((target / "input" / "daten.xlsx").read_bytes(), b"input") + self.assertEqual((target / "output" / "2026" / "nachweis.pdf").read_bytes(), b"output") + self.assertEqual((target / "template" / "vorlage.pdf").read_bytes(), b"new template") + self.assertEqual((target / "template" / "eigene.pdf").read_bytes(), b"own template") + # Die alte Installation bleibt unverändert. + self.assertEqual((self.current / EXE_NAME).read_bytes(), b"old") + self.assertEqual([p.name for p in self.root.iterdir() if p.name.startswith(".")], []) + + def test_keeps_changed_settings_next_to_new_ones(self): + (self.current / "config" / "settings.ini").write_text("[a]\nx=2\n", encoding="utf-8") + result = install_update(self.make_zip(), self.current) + config = result.target_dir / "config" + self.assertEqual((config / "settings.ini").read_text(encoding="utf-8"), "[a]\nx=1\n") + self.assertEqual(result.previous_settings, config / "settings.vorherige-version.ini") + self.assertEqual(result.previous_settings.read_text(encoding="utf-8"), "[a]\nx=2\n") + + def test_existing_installation_is_only_reused(self): + zip_path = self.make_zip() + install_update(zip_path, self.current) + result = install_update(zip_path, self.current) + self.assertTrue(result.already_installed) + + def test_rejects_package_without_exe(self): + with self.assertRaisesRegex(UpdateError, "keine"): + install_update(self.make_zip({"README.md": b"x"}), self.current) + self.assertFalse((self.root / "Tätigkeitsnachweis-Generator v1.4.0").exists()) + + def test_rejects_path_traversal(self): + with self.assertRaisesRegex(UpdateError, "ungültigen Pfad"): + install_update(self.make_zip({EXE_NAME: b"x", "../evil.txt": b"x"}), self.current) + self.assertFalse((self.root / "evil.txt").exists()) + + def test_rejects_broken_zip_and_cleans_up(self): + broken = self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip" + broken.write_bytes(b"no zip") + with self.assertRaisesRegex(UpdateError, "keine gültige ZIP"): + install_update(broken, self.current) + self.assertEqual(sorted(p.name for p in self.root.iterdir()), sorted([self.current.name, "share"])) + + def test_unreachable_share_gives_vpn_hint(self): + with self.assertRaisesRegex(UpdateError, "VPN"): + install_update(self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip", self.current) + + def test_refuses_existing_folder_without_program(self): + (self.root / "Tätigkeitsnachweis-Generator v1.4.0").mkdir() + with self.assertRaisesRegex(UpdateError, "existiert bereits"): + install_update(self.make_zip(), self.current) + + +class StartApplicationTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == "win32", "Windows-Prozessflags") + def test_starts_new_exe_with_clean_bundle_environment(self): + target = Path("C:/neu") + environment = {"PATH": "x", "APP_BASE_DIR": "alt", "_MEIPASS2": "alt", + "_PYI_APPLICATION_HOME_DIR": "alt", "STREAMLIT_SERVER_PORT": "8501"} + with patch.dict(update_installer.os.environ, environment, clear=True), \ + patch.object(update_installer.subprocess, "Popen") as popen: + update_installer.start_application(target) + args, kwargs = popen.call_args + self.assertEqual(args[0], [str(target / EXE_NAME)]) + self.assertEqual(kwargs["cwd"], target) + self.assertEqual(kwargs["env"], {"PATH": "x", "PYINSTALLER_RESET_ENVIRONMENT": "1"}) + self.assertTrue(kwargs["creationflags"] & subprocess.CREATE_NEW_CONSOLE) + + +if __name__ == "__main__": + unittest.main() diff --git a/0_application/update_installer.py b/0_application/update_installer.py new file mode 100644 index 0000000..04c2089 --- /dev/null +++ b/0_application/update_installer.py @@ -0,0 +1,137 @@ +"""Richtet eine neue Programmversion neben der aktuellen Installation ein. + +Ablauf von "Anwendung ersetzen": +1. ZIP vom SMB-Laufwerk lokal zwischenspeichern und prüfen. +2. In einen Zwischenordner neben der aktuellen Installation entpacken. +3. input/ und output/ sowie eigene Template-PDFs übernehmen. +4. Zwischenordner in "Tätigkeitsnachweis-Generator vX.Y.Z" umbenennen. +5. Neue EXE starten; sie beendet die alte Instanz nach Rückfrage selbst. + +Die alte Installation bleibt unverändert als Rückfallebene erhalten. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +EXE_NAME = "Tätigkeitsnachweis-Generator.exe" +USER_DATA_DIRS = ("input", "output") +SETTINGS_FILE = Path("config") / "settings.ini" + + +class UpdateError(Exception): + """Fehler mit einer Meldung, die direkt angezeigt werden kann.""" + + +@dataclass +class UpdateResult: + target_dir: Path + already_installed: bool = False + previous_settings: Path | None = None + + +def can_replace_application() -> bool: + """Nur die gebaute Windows-EXE kann sich selbst ersetzen.""" + return sys.platform == "win32" and bool(getattr(sys, "frozen", False)) + + +def _check_members(package: zipfile.ZipFile) -> None: + names = package.namelist() + for name in names: + path = PurePosixPath(name.replace("\\", "/")) + if path.is_absolute() or ".." in path.parts or ":" in name: + raise UpdateError(f"Das Update-Paket enthält einen ungültigen Pfad: {name}") + if EXE_NAME not in names: + raise UpdateError(f"Das Update-Paket enthält keine {EXE_NAME}.") + if package.testzip() is not None: + raise UpdateError("Das Update-Paket ist beschädigt. Bitte später erneut versuchen.") + + +def _copy_user_data(current_dir: Path, staging_dir: Path) -> None: + for folder in USER_DATA_DIRS: + source = current_dir / folder + if source.is_dir(): + shutil.copytree(source, staging_dir / folder, dirs_exist_ok=True) + # Eigene Templates übernehmen, ohne die mitgelieferten zu überschreiben. + templates = current_dir / "template" + if templates.is_dir(): + (staging_dir / "template").mkdir(exist_ok=True) + for path in templates.iterdir(): + destination = staging_dir / "template" / path.name + if path.is_file() and not destination.exists(): + shutil.copy2(path, destination) + + +def _keep_previous_settings(current_dir: Path, staging_dir: Path) -> Path | None: + """Neue settings.ini gilt; abweichende alte Einstellungen bleiben daneben erhalten.""" + old, new = current_dir / SETTINGS_FILE, staging_dir / SETTINGS_FILE + if not old.is_file() or (new.is_file() and old.read_bytes() == new.read_bytes()): + return None + new.parent.mkdir(parents=True, exist_ok=True) + backup = new.with_name("settings.vorherige-version.ini") + shutil.copy2(old, backup) + return backup + + +def install_update(zip_path: Path, current_dir: Path) -> UpdateResult: + """Entpackt zip_path neben current_dir und übernimmt die Nutzerdaten.""" + current_dir = current_dir.resolve() + target_dir = current_dir.parent / zip_path.stem + if target_dir == current_dir: + raise UpdateError("Diese Version ist bereits die laufende Installation.") + if (target_dir / EXE_NAME).is_file(): + return UpdateResult(target_dir, already_installed=True) + if target_dir.exists(): + raise UpdateError( + f"Der Ordner {target_dir} existiert bereits, enthält aber kein Programm. " + "Bitte den Ordner entfernen oder umbenennen und erneut versuchen.") + + staging_dir = current_dir.parent / f".{zip_path.stem}.update" + try: + shutil.rmtree(staging_dir, ignore_errors=True) + with tempfile.TemporaryDirectory(prefix="taetigkeitsnachweis-update-") as temporary: + # Erst lokal kopieren: ein VPN-Abbruch trifft so nur die Kopie. + local_zip = Path(temporary) / zip_path.name + try: + shutil.copyfile(zip_path, local_zip) + except OSError as exc: + raise UpdateError( + "Das Update konnte nicht vom Netzlaufwerk geladen werden. " + "Bitte VPN-Verbindung und Laufwerk J: prüfen.") from exc + try: + with zipfile.ZipFile(local_zip) as package: + _check_members(package) + package.extractall(staging_dir) + except zipfile.BadZipFile as exc: + raise UpdateError("Das Update-Paket ist keine gültige ZIP-Datei.") from exc + _copy_user_data(current_dir, staging_dir) + previous_settings = _keep_previous_settings(current_dir, staging_dir) + staging_dir.rename(target_dir) + except UpdateError: + shutil.rmtree(staging_dir, ignore_errors=True) + raise + except OSError as exc: + shutil.rmtree(staging_dir, ignore_errors=True) + raise UpdateError( + f"Die neue Version konnte nicht eingerichtet werden ({exc}). " + f"Bitte Schreibrechte für {current_dir.parent} prüfen.") from exc + if previous_settings is not None: + previous_settings = target_dir / SETTINGS_FILE.parent / previous_settings.name + return UpdateResult(target_dir, previous_settings=previous_settings) + + +def start_application(target_dir: Path) -> None: + """Startet die neue EXE in einem eigenen Konsolenfenster wie per Doppelklick.""" + env = {key: value for key, value in os.environ.items() + if key != "APP_BASE_DIR" and not key.startswith(("STREAMLIT_", "_MEIPASS", "_PYI_"))} + # Sonst übernimmt die neue EXE Pfade aus dem Bundle der laufenden alten EXE. + env["PYINSTALLER_RESET_ENVIRONMENT"] = "1" + subprocess.Popen( + [str(target_dir / EXE_NAME)], cwd=target_dir, env=env, close_fds=True, + creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP, + ) diff --git a/1_build-pipeline/.last_version b/1_build-pipeline/.last_version index 8c9698a..840ca8c 100644 --- a/1_build-pipeline/.last_version +++ b/1_build-pipeline/.last_version @@ -1 +1 @@ -1.3.4 \ No newline at end of file +1.4 \ No newline at end of file diff --git a/1_build-pipeline/build.spec b/1_build-pipeline/build.spec index 192c880..c3ea251 100644 --- a/1_build-pipeline/build.spec +++ b/1_build-pipeline/build.spec @@ -77,6 +77,7 @@ except ImportError: datas = [ (str(_APP_DIR / "app.py"), "."), (str(_APP_DIR / "version_check.py"), "."), + (str(_APP_DIR / "update_installer.py"), "."), (str(_APP_DIR / "CHANGELOG.md"), "."), (str(_APP_DIR / "src"), "src"), (str(_APP_DIR / "config"), "config"),