7 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
Rob fb191449d1 wording für windows angepasst 2026-11-03 11:58:53 +01:00
Rob 4e3b2237ff Neues PDF wurde geliefert und entspechend für das Programm vorbereitet (siehe README im template folder) 2026-11-03 11:58:32 +01:00
RobandClaude Sonnet 5.5 7de6984ebc build: alle Template-PDFs automatisch in Build und Release aufnehmen
Alle *.pdf im template-Ordner werden ausgeliefert; Dateien mit '_' oder '.'
am Anfang sowie andere Dateitypen werden ignoriert. Veraltete PDFs im
Build-Ordner werden entfernt. Tests entsprechend angepasst.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 11:53:55 +01:00
Rob 2237779e56 add: wenn input folder leer -> hochladen von xlxs Dateien vereinfacht 2026-10-07 21:16:46 +02:00
17 changed files with 502 additions and 21 deletions

No files matched your search

+28
View File
@@ -7,8 +7,36 @@ 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
### Hinzugefügt
- **Input-Dateien hinzufügen**: Bei leerem Input-Ordner können `.xlsx`-Dateien per Drag-and-drop oder Dateiauswahl in den Input-Ordner kopiert werden
- **Input-Ordner öffnen**: Ein Button öffnet den Input-Ordner direkt im Explorer
- **Input-Ordner neu einlesen**: Dateiliste kann über Button aktualisiert werden
### Behoben
- EXE erkennt laufende Entwicklungsinstanz und fragt vor dem Neustart
+19 -1
View File
@@ -9,6 +9,10 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
## Bedienung
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
- Bei leerem Input-Ordner: „Input-Ordner öffnen“ öffnet den Explorer.
- Excel-Dateien können per Drag-and-drop hochgeladen werden. Sie werden in den
Input-Ordner kopiert; danach wird die Dateiliste automatisch neu eingelesen.
- Nach manuellem Kopieren im Explorer „Input-Ordner neu einlesen“ anklicken.
- Nach Auswahl wird die Datei sofort validiert
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
@@ -21,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:
@@ -54,4 +72,4 @@ Mapping von Input-Feldern auf Template-Zellen:
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
+98 -6
View File
@@ -30,6 +30,15 @@ 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):
directory.mkdir(parents=True, exist_ok=True)
if sys.platform == "win32":
os.startfile(str(directory))
else:
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", str(directory)], check=True)
def set_active_employee(employee_name: str):
@@ -83,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'''<style>
@@ -97,7 +106,7 @@ try:
#update-notice-dismiss:checked + .update-notice {{ display:none; }}
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--footer {{ min-height:26px; }}
.update-notice__actions--footer {{ min-height:60px; }}
.update-notice__button {{
display:inline-flex; align-items:center; justify-content:center;
width:26px; height:26px; color:#5f4700; background:#fffdf3;
@@ -106,11 +115,12 @@ try:
}}
.update-notice__button:hover {{ background:#f6e7b2; }}
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"] 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;
}}
</style>
@@ -128,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
@@ -155,9 +192,64 @@ col1, col2 = st.columns(2)
with col1:
st.subheader("1. Input-Datei")
input_files = get_available_input_files(app_config)
for message in st.session_state.pop("input_upload_messages", []):
st.success(message)
for message in st.session_state.pop("input_upload_errors", []):
st.error(message)
if not input_files:
st.warning("Keine .xlsx-Dateien im input/-Ordner gefunden.")
with st.container(border=True, key="input_file_panel"):
st.markdown("**Excel-Datei hinzufügen**")
st.caption("Keine .xlsx-Dateien im Input/-Ordner gefunden.")
input_directory = Path(app_config.input_dir)
uploaded_files = st.file_uploader(
"Excel-Dateien hierher ziehen oder auswählen",
type=["xlsx"], accept_multiple_files=True,
key=f"input_upload_{st.session_state.get('input_upload_generation', 0)}",
label_visibility="collapsed",
)
if uploaded_files:
saved, errors = [], []
for uploaded_file in uploaded_files:
try:
name = uploaded_file.name
if (Path(name).name != name or "/" in name or "\\" in name
or Path(name).suffix.lower() != ".xlsx" or name.startswith("~")):
raise ValueError("Bitte eine .xlsx-Datei mit gültigem Dateinamen auswählen.")
input_directory.mkdir(parents=True, exist_ok=True)
destination = input_directory / name
counter = 1
while True:
try:
with destination.open("xb") as output:
output.write(uploaded_file.getbuffer())
break
except FileExistsError:
counter += 1
destination = input_directory / f"{Path(name).stem} ({counter}).xlsx"
saved.append(f"{destination.name} wurde in den Input-Ordner kopiert.")
except (OSError, ValueError) as exc:
errors.append(f"{uploaded_file.name} konnte nicht übernommen werden: {exc}")
st.session_state.input_upload_messages = saved
st.session_state.input_upload_errors = errors
st.session_state.input_upload_generation = st.session_state.get("input_upload_generation", 0) + 1
st.session_state.last_input_file = None
st.rerun()
st.caption("Dateien hierher ziehen oder auswählen. Sie werden automatisch im Input-Ordner gespeichert.")
folder_action, refresh_action = st.columns(2)
with folder_action:
if st.button("Ordner öffnen", icon="📁", key="open_input_directory", use_container_width=True):
try:
open_input_folder(input_directory)
except (OSError, subprocess.SubprocessError) as exc:
st.error(f"Der Input-Ordner konnte nicht geöffnet werden: {exc}")
with refresh_action:
if st.button("Neu einlesen", icon="🔄", key="refresh_input_directory", use_container_width=True,
help="Dateiliste aktualisieren, nachdem Dateien im Explorer hinzugefügt wurden."):
st.rerun()
selected_input = None
st.session_state.parsed_records = []
st.session_state.input_validated = False
st.session_state.last_input_file = None
else:
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
if len(input_files) == 1:
@@ -484,7 +576,7 @@ if "generated_files" in st.session_state and st.session_state.generated_files:
key=f"open_folder_{i}",
on_click=open_path,
args=(str(fpath.parent),),
help="Ordner im Finder öffnen",
help="Ordner im Explorer öffnen",
use_container_width=True,
)
+1 -1
View File
@@ -123,7 +123,7 @@ def main():
# ── Datei öffnen (macOS) ──────────────────────────────────────────
if sys.platform == "darwin":
print()
print("[INFO] Öffne Datei im Finder…")
print("[INFO] Öffne Datei im Explorer")
subprocess.run(["open", str(output_file)], check=False)
print()
@@ -0,0 +1,34 @@
# Aktualisieren der PDF Datei
Wenn sich die PDF-Datei welche als Vorlagen genutzt wird geändert hat, kann diese nicht direkt vom Programm verwendet werden, da es die zu verwendenden Felder nicht erkennen kann. Wir müssen ihm kurz zeigen wo die zu nutzenden Felder sind.
Öffne Dazu eine bereits bestehende *.odg Datei im Ordner template und schau Dir die Textfelder an, welche in die entsprechenden Felder gelegt, damit das Programm diese später erkennen kann.
Öffne nun das neue PDF mit dem Programm Libre Office Writer und erzeuge in jedem Formularfeld ein Textfeld mit dem entspechenden Feldnamen wie im Beispiel-odg. Die Felder heißen einfach so wie sie im PDF genutzt werden plus Unterstrich Field. Also:
- Antragsnummer_Field
- Name_Field
- Qulifikation_Field
- Funktion_Field
- Stellenanteil_Field
- Monat_Field
In das Feld Tätigkeiten muss kein neues Textfeld eingefügt werden, weil es schon ein Textfeld ist, daher kann in dieses direkt
- Tätigkeiten_Field
eingetragen werden.
Auf Seite zwei muss nur ein Feld
- Datum_Field
erzeugt werden. Denn dieses kann einfach kopiert und im zweiten Datumfeld plaziert werden.
Für die beiden Ortsfelder muss auch jeweils ein Textfeld eingefügt werden, dieses enthält aber einfach den Text:
- Dresden
da es vom Programm nicht geändert wird
# Styling
Um einen einheitlichen look zu garantieren müssen alle eingefügten Textfelder wie folgt gestyled sein:
- das Textfeld selber füllt das komplette Feld bis an alle 4 Ränder aus
- Textgröße: 12 pt
- Font/Schriftart: Arial
- Absatz Textausrichtung: mittig
+8 -2
View File
@@ -17,11 +17,15 @@ build = importlib.util.module_from_spec(spec)
spec.loader.exec_module(build)
TEMPLATE_NAMES = ["template/A Vorlage.pdf", "template/B Vorlage.PDF"]
IGNORED_NAMES = ["template/_alt.pdf", "template/.versteckt.pdf", "template/A Vorlage.odg"]
@pytest.fixture
def release(tmp_path):
directory = tmp_path / "build"
directory.mkdir()
for name in deploy.PACKAGE_FILES:
for name in deploy.PACKAGE_FILES + TEMPLATE_NAMES:
path = directory / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"test release payload")
@@ -33,13 +37,15 @@ def release(tmp_path):
def test_deployment_packages_release_without_work_files(release):
directory, target = release
for name in IGNORED_NAMES:
(directory / name).write_bytes(b"ignored")
for folder in ["input", "output"]:
(directory / folder).mkdir()
(directory / folder / "private.xlsx").write_bytes(b"private")
result = deploy.deploy(directory, target)
assert result.name == "Tätigkeitsnachweis-Generator v1.3.0.zip"
with zipfile.ZipFile(result) as archive:
assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + ["input/", "output/"])
assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + TEMPLATE_NAMES + ["input/", "output/"])
assert archive.testzip() is None
assert not list(target.glob(".upload-*"))
# An unchanged release can be retried without overwriting the published ZIP.
+1 -1
View File
@@ -15,7 +15,7 @@ from src.writer import generate_activity_reports
CONFIG_DIR = PROJECT_ROOT / "config"
INPUT_FILE = Path(__file__).resolve().parent / "fixture" / "2026 JM.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "06-2026 Tätigkeitsnachweise Stellenförderung.pdf"
@pytest.fixture(scope="module")
@@ -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.2
1.4
+32 -5
View File
@@ -21,7 +21,7 @@ from datetime import date
from pathlib import Path
from typing import Optional
from deploy import deploy, write_release_manifest
from deploy import deploy, is_template_pdf, write_release_manifest
# ── Pfade ───────────────────────────────────────────────────────
BUILD_PIPELINE_DIR = Path(__file__).resolve().parent
@@ -144,6 +144,20 @@ def update_changelog(
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:
"""Baut die Sektionen für den Changelog-Eintrag."""
if message:
@@ -223,10 +237,17 @@ def _prepare_build_dir() -> None:
shutil.copy2(f, config_dst / f.name)
shutil.copy2(CHANGELOG_PATH, BUILD_OUTPUT_DIR / "CHANGELOG.md")
shutil.copy2(
APP_DIR / "template" / "Tätigkeitsnachweise Stellenförderung.pdf",
BUILD_OUTPUT_DIR / "template" / "Tätigkeitsnachweise Stellenförderung.pdf",
)
# Alle Template-PDFs ausliefern (ohne Dateien mit '_' oder '.' am Anfang).
# Veraltete PDFs aus früheren Builds werden entfernt.
tpl_src = APP_DIR / "template"
tpl_dst = BUILD_OUTPUT_DIR / "template"
wanted = {p.name for p in tpl_src.iterdir() if is_template_pdf(p)}
for old_pdf in tpl_dst.iterdir():
if is_template_pdf(old_pdf) and old_pdf.name not in wanted:
old_pdf.unlink()
for name in sorted(wanted):
shutil.copy2(tpl_src / name, tpl_dst / name)
# README für input-Ordner
input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt"
@@ -346,6 +367,10 @@ def main() -> None:
"--message", "-m",
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(
"--dry-run",
action="store_true",
@@ -399,6 +424,8 @@ def main() -> None:
if not args.no_bump:
print("\n📝 Changelog …")
update_changelog(new_version, bump_type, args.message)
if not args.no_pause and sys.stdin.isatty():
_wait_for_changelog_edit()
_prepare_build_dir()
exe_path = _run_pyinstaller()
final_exe = _copy_to_build(exe_path)
+1
View File
@@ -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"),
+21 -4
View File
@@ -18,10 +18,27 @@ MANIFEST_NAME = "release.json"
PACKAGE_FILES = [
EXE_NAME, "README.md", "CHANGELOG.md",
"config/settings.ini", "config/input-fields.json", "config/output-fields.json",
"template/Tätigkeitsnachweise Stellenförderung.pdf",
]
def is_template_pdf(path: Path) -> bool:
"""Nur *.pdf, die nicht mit '_' oder '.' beginnen, gelten als Template."""
return path.is_file() and path.suffix.lower() == ".pdf" and not path.name.startswith(("_", "."))
def template_files(base_dir: Path) -> list[str]:
"""Relative Pfade aller Template-PDFs in base_dir/template (sortiert)."""
folder = base_dir / "template"
if not folder.is_dir():
return []
return [f"template/{p.name}" for p in sorted(folder.iterdir()) if is_template_pdf(p)]
def package_files(build_dir: Path) -> list[str]:
"""Alle Dateien des Release-Pakets: feste Dateien plus alle Template-PDFs."""
return PACKAGE_FILES + template_files(build_dir)
def normalize_version(version: str) -> str:
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", version):
raise ValueError(f"Ungültige Versionsnummer: {version}")
@@ -40,7 +57,7 @@ def write_release_manifest(build_dir: Path, version: str) -> None:
"""Record the version and all payload hashes after a successful build."""
manifest = {
"version": normalize_version(version),
"files": {name: file_hash(build_dir / name) for name in PACKAGE_FILES},
"files": {name: file_hash(build_dir / name) for name in package_files(build_dir)},
}
(build_dir / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
@@ -51,7 +68,7 @@ def validate_release(build_dir: Path) -> str:
raise ValueError("Kein bestätigter Build vorhanden. Bitte zuerst build.bat ausführen.")
manifest = json.loads((build_dir / MANIFEST_NAME).read_text(encoding="utf-8"))
version = normalize_version(manifest["version"])
for name in PACKAGE_FILES:
for name in package_files(build_dir):
path = build_dir / name
if not path.is_file() or file_hash(path) != manifest.get("files", {}).get(name):
raise ValueError(f"Build-Datei fehlt oder wurde nach dem Build verändert: {name}. Bitte neu bauen.")
@@ -83,7 +100,7 @@ def deploy(build_dir: Path = BUILD_DIR, target_dir: Path = DEPLOY_DIR) -> Path:
temporary = Path(temporary)
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as package:
for name in PACKAGE_FILES:
for name in package_files(build_dir):
package.write(build_dir / name, name)
for directory in ["input/", "output/"]:
# Fixed timestamps keep repeated deployments byte-identical.