3 Commits
Author SHA1 Message Date
RobandClaude Opus 5.5 cb059ac20d Geführtes Update: "Anwendung ersetzen" im Update-Hinweis
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 <noreply@anthropic.com>
2026-11-03 12:55:12 +01:00
Rob 5e2fab7fd3 Template PDF erneuert. Neues und altes PDF sind nun enthalten 2026-11-03 12:15:10 +01:00
Rob 0ec5de50bb build pipeline wartet nun darauf das das changelog aktualisiert wurde 2026-11-03 12:06:05 +01:00
8 changed files with 349 additions and 5 deletions

No files matched your search

+22
View File
@@ -7,6 +7,28 @@ 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
### Aktualisiert
- Template PDF erneuert. Neues und altes PDF sind nun enthalten
---
## [1.3.3] – 2026-10-07
### Behoben
- internal development changes
---
## [1.3.2] – 2026-10-07 ## [1.3.2] – 2026-10-07
### Hinzugefügt ### Hinzugefügt
+14
View File
@@ -25,6 +25,20 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
5. **"Tätigkeitsnachweise generieren"** klicken 5. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt - 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 ## Konfiguration
Alle Einstellungen liegen im `config/`-Ordner: Alle Einstellungen liegen im `config/`-Ordner:
+33 -4
View File
@@ -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.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports from src.writer import generate_activity_reports
from version_check import UPDATE_DIR, get_latest_version 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): def open_input_folder(directory: Path):
@@ -91,7 +92,7 @@ try:
local_version, latest_version = get_latest_version() local_version, latest_version = get_latest_version()
current_version_text = f"v{'.'.join(map(str, local_version))}" current_version_text = f"v{'.'.join(map(str, local_version))}"
if latest_version: if latest_version:
available_version, _ = latest_version available_version, update_path = latest_version
version_text = ".".join(map(str, available_version)) version_text = ".".join(map(str, available_version))
st.html( st.html(
f'''<style> f'''<style>
@@ -105,7 +106,7 @@ try:
#update-notice-dismiss:checked + .update-notice {{ display:none; }} #update-notice-dismiss:checked + .update-notice {{ display:none; }}
body:has(#update-notice-dismiss:checked) div.st-key-update_notice_open {{ display:none!important; }} body:has(#update-notice-dismiss:checked) div.st-key-update_notice_open {{ display:none!important; }}
.update-notice__actions {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }} .update-notice__actions {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }}
.update-notice__actions--footer {{ min-height:26px; }} .update-notice__actions--footer {{ min-height:60px; }}
.update-notice__button {{ .update-notice__button {{
display:inline-flex; align-items:center; justify-content:center; display:inline-flex; align-items:center; justify-content:center;
width:26px; height:26px; color:#5f4700; background:#fffdf3; width:26px; height:26px; color:#5f4700; background:#fffdf3;
@@ -114,11 +115,12 @@ try:
}} }}
.update-notice__button:hover {{ background:#f6e7b2; }} .update-notice__button:hover {{ background:#f6e7b2; }}
div.st-key-update_notice_open {{ div.st-key-update_notice_open {{
position:fixed; top:8.5rem; right:3.75rem; z-index:10001; width:26px; position:fixed; top:8.5rem; right:calc(1rem + 13px); z-index:10001;
width:264px; gap:6px;
}} }}
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }} div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
div.st-key-update_notice_open div[data-testid="stButton"] button {{ div.st-key-update_notice_open div[data-testid="stButton"] button {{
min-width:200px; min-height:26px; height:26px; padding:0; color:#5f4700; width:100%; min-height:26px; height:26px; padding:0; color:#5f4700;
background:#fffdf3; border:1px solid #d9bd66; border-radius:5px; background:#fffdf3; border:1px solid #d9bd66; border-radius:5px;
}} }}
</style> </style>
@@ -136,6 +138,33 @@ try:
if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"): if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"):
if sys.platform == "win32": if sys.platform == "win32":
os.startfile(str(UPDATE_DIR)) 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: except Exception:
pass pass
@@ -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()
+137
View File
@@ -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,
)
+1 -1
View File
@@ -1 +1 @@
1.3.3 1.4
+20
View File
@@ -144,6 +144,20 @@ def update_changelog(
print(f" ✓ CHANGELOG.md aktualisiert → [{new_version}]") print(f" ✓ CHANGELOG.md aktualisiert → [{new_version}]")
def _wait_for_changelog_edit() -> None:
"""Gibt dem Benutzer Zeit, den neuen Changelog-Eintrag zu bearbeiten.
Der Build liest CHANGELOG.md erst nach dieser Pause; Änderungen in der
Datei fließen also in den Build ein.
"""
print(f"\n✏️ Bitte den neuen Eintrag jetzt bearbeiten und speichern:\n {CHANGELOG_PATH}")
try:
input(" Danach Enter drücken, um den Build fortzusetzen (Strg+C bricht ab) … ")
except (EOFError, KeyboardInterrupt):
print("\nAbgebrochen.")
raise KeyboardInterrupt
def _build_sections(bump_type: str, message: Optional[str]) -> str: def _build_sections(bump_type: str, message: Optional[str]) -> str:
"""Baut die Sektionen für den Changelog-Eintrag.""" """Baut die Sektionen für den Changelog-Eintrag."""
if message: if message:
@@ -353,6 +367,10 @@ def main() -> None:
"--message", "-m", "--message", "-m",
help="Changelog-Beschreibung für diese Version", help="Changelog-Beschreibung für diese Version",
) )
parser.add_argument(
"--no-pause", action="store_true",
help="Nicht auf die Bearbeitung des Changelogs warten (z.B. für CI)",
)
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
@@ -406,6 +424,8 @@ def main() -> None:
if not args.no_bump: if not args.no_bump:
print("\n📝 Changelog …") print("\n📝 Changelog …")
update_changelog(new_version, bump_type, args.message) update_changelog(new_version, bump_type, args.message)
if not args.no_pause and sys.stdin.isatty():
_wait_for_changelog_edit()
_prepare_build_dir() _prepare_build_dir()
exe_path = _run_pyinstaller() exe_path = _run_pyinstaller()
final_exe = _copy_to_build(exe_path) final_exe = _copy_to_build(exe_path)
+1
View File
@@ -77,6 +77,7 @@ except ImportError:
datas = [ datas = [
(str(_APP_DIR / "app.py"), "."), (str(_APP_DIR / "app.py"), "."),
(str(_APP_DIR / "version_check.py"), "."), (str(_APP_DIR / "version_check.py"), "."),
(str(_APP_DIR / "update_installer.py"), "."),
(str(_APP_DIR / "CHANGELOG.md"), "."), (str(_APP_DIR / "CHANGELOG.md"), "."),
(str(_APP_DIR / "src"), "src"), (str(_APP_DIR / "src"), "src"),
(str(_APP_DIR / "config"), "config"), (str(_APP_DIR / "config"), "config"),