From 1fb64444441f4067c79940cdf66e325c875c79f2 Mon Sep 17 00:00:00 2001 From: Rob Date: Fri, 8 May 2026 11:55:36 +0200 Subject: [PATCH] add: Installer funktionen mit auto Update check --- .github/copilot-plan.md | 35 +++++++++ .gitignore | 1 + README.md | 45 ++++++++++++ build.bat | 109 ++++++++++++++++++++++++++-- config.json | 5 +- gui.py | 134 ++++++++++++++++++++++++++++++++++- installer/AnwesenheitPDF.iss | 99 ++++++++++++++++++++++++++ schema.json | 4 ++ 8 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 installer/AnwesenheitPDF.iss diff --git a/.github/copilot-plan.md b/.github/copilot-plan.md index 96bd6b4..bad3d36 100644 --- a/.github/copilot-plan.md +++ b/.github/copilot-plan.md @@ -61,3 +61,38 @@ Excel-Datei → excel_reader → MonthlyAttendance → pdf_filler → PDF-Datei | Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "1" | | "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | "0" | | Leer | (überspringen) | — | — | — | — | + +--- + +## Windows-Installer (Inno Setup) + +### Ziel +Installer-EXE erstellen, die: +- Von einem Windows SMB-Share aus gestartet werden kann (self-contained .exe) +- Das Programm lokal unter `%ProgramFiles%\AnwesenheitPDF\` installiert +- Einen Startmenü-Eintrag anlegt (optional: Desktop-Verknüpfung) +- Einen Uninstaller registriert (Programme und Features) + +### Tool: Inno Setup 6 +- Free, open-source, weit verbreitet +- Erzeugt eine einzelne selbstextrahierende .exe (ideal für SMB-Share) +- Unterstützt German.isl (deutschsprachiger Wizard) +- ISCC.exe (Compiler) muss auf Build-Maschine installiert sein +- Installation: `winget install --id JRSoftware.InnoSetup -e -s winget -i` + +### Dateien +- `installer\AnwesenheitPDF.iss` — Inno Setup Script +- `build.bat` — um Installer-Schritt erweitert (ruft ISCC auf) +- Ausgabe: `dist\AnwesenheitPDF-Setup-x.y.z.exe` + +### Ablauf build.bat (neu: Schritt 4) +1. pip install +2. update_version.py (liest Version aus config.json) +3. PyInstaller → dist\AnwesenheitPDF\ +4. Version aus config.json extrahieren → ISCC aufrufen → dist\AnwesenheitPDF-Setup-x.y.z.exe + +### Install-Ziel +- `{autopf}\AnwesenheitPDF\` (= Program Files, UAC-geschützt) +- Requires admin (PrivilegesRequired=admin) +- Startmenü: `{group}\AnwesenheitPDF` +- Optional Desktop-Verknüpfung (Checkbox im Wizard) diff --git a/.gitignore b/.gitignore index 9f5d196..3ccb6be 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ tests/fixtures/**/*.xlsx tests/fixtures/**/*.xlsm tests/fixtures/**/reference/*.pdf tests/fixtures/**/diff/ +*.log diff --git a/README.md b/README.md index 907595f..3607d20 100644 --- a/README.md +++ b/README.md @@ -198,3 +198,48 @@ Das Programm prüft vor jeder Generierung: 7. Headerzeile mit `Name, Vorname` gefunden 8. Tagesspalten ab Spaltenindex 9 vorhanden 9. PDF-Template unter `template/sab.pdf` erreichbar + +--- + +## Windows-Installer erstellen + +Der Build-Prozess erzeugt neben dem Programm auch einen Windows-Installer (`.exe`), der vom SMB-Netzlaufwerk gestartet werden kann. + +### Voraussetzung: Inno Setup 6 + +Inno Setup 6 muss auf der Build-Maschine installiert sein. Installation über `winget`: + +```bat +winget install --id JRSoftware.InnoSetup -e -s winget -i +``` + +Alternativ manueller Download: + +### Installer bauen + +```bat +build.bat +``` + +`build.bat` führt alle vier Schritte automatisch aus: + +1. Abhängigkeiten installieren (`pip install`) +2. Version aktualisieren (`update_version.py`) +3. Programm packen (`PyInstaller` → `dist\AnwesenheitPDF\`) +4. Installer kompilieren (`ISCC.exe` → `dist\AnwesenheitPDF-Setup-x.y.z.exe`) + +Ist Inno Setup **nicht** installiert, wird Schritt 4 mit einer Warnung übersprungen — der PyInstaller-Output in `dist\AnwesenheitPDF\` ist trotzdem nutzbar. + +### Installer verteilen + +Die fertige Datei `dist\AnwesenheitPDF-Setup-x.y.z.exe` auf das SMB-Netzlaufwerk kopieren. Von dort kann sie direkt gestartet werden — ohne vorherige lokale Kopie. + +### Was der Installer macht + +| Aktion | Details | +|---|---| +| Installationspfad | `%ProgramFiles%\AnwesenheitPDF\` (Admin erforderlich) | +| Startmenü-Eintrag | `AnwesenheitPDF` | +| Desktop-Verknüpfung | Optional (Checkbox im Wizard, Standard: deaktiviert) | +| Uninstaller | Registriert unter *Programme und Features* | +| Windows-Version | Windows 10 oder höher | diff --git a/build.bat b/build.bat index ff8c7de..66f28ad 100644 --- a/build.bat +++ b/build.bat @@ -1,12 +1,14 @@ @echo off -REM Build-Script fuer AnwesenheitPDF.exe +REM Build-Script fuer AnwesenheitPDF.exe + Windows-Installer REM REM Ergebnis: REM build/ - temporaere PyInstaller-Artefakte REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten) +REM + AnwesenheitPDF-Setup-x.y.z.exe (Windows-Installer) REM REM Voraussetzungen: REM pip install -r requirements-dev.txt +REM Inno Setup 6 installiert (https://jrsoftware.org/isinfo.php) cd /d "%~dp0" @@ -14,7 +16,7 @@ echo. echo === AnwesenheitPDF Build === echo. -echo [1/3] Abhaengigkeiten installieren... +echo [1/4] Abhaengigkeiten installieren... pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet if %ERRORLEVEL% neq 0 ( echo [FEHLER] pip install fehlgeschlagen. @@ -22,7 +24,7 @@ if %ERRORLEVEL% neq 0 ( exit /b 1 ) -echo [2/3] Version aktualisieren... +echo [2/4] Version aktualisieren... python update_version.py if %ERRORLEVEL% neq 0 ( echo [FEHLER] Version-Update fehlgeschlagen. @@ -30,7 +32,7 @@ if %ERRORLEVEL% neq 0 ( exit /b 1 ) -echo [3/3] Build starten... +echo [3/4] Build starten... echo. python -m PyInstaller ^ @@ -59,11 +61,104 @@ if not exist "dist\AnwesenheitPDF\template" mkdir "dist\AnwesenheitPDF\template" if not exist "dist\AnwesenheitPDF\output" mkdir "dist\AnwesenheitPDF\output" if exist "template\sab.pdf" copy /Y "template\sab.pdf" "dist\AnwesenheitPDF\template\sab.pdf" >nul +echo. +echo [4/4] Windows-Installer erstellen... + +REM Version aus config.json lesen (Format: "x.y.z+hash" -> nur "x.y.z" verwenden) +for /f "tokens=2 delims=:," %%v in ('findstr /c:"\"version\"" config.json') do ( + set RAW_VERSION=%%v +) +REM Fuehrende/nachfolgende Leerzeichen und Anfuehrungszeichen entfernen, Hash abschneiden +set RAW_VERSION=%RAW_VERSION: =% +set RAW_VERSION=%RAW_VERSION:"=% +for /f "tokens=1 delims=+" %%c in ("%RAW_VERSION%") do set APP_VERSION=%%c + +echo Version: %APP_VERSION% + +REM Inno Setup Compiler suchen (Standard-Installationspfade) +set ISCC_PATH= +if exist "%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe" ( + set "ISCC_PATH=%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe" +) else if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" ( + set "ISCC_PATH=%ProgramFiles%\Inno Setup 6\ISCC.exe" +) else if exist "%LOCALAPPDATA%\Programs\Inno Setup 6\ISCC.exe" ( + set "ISCC_PATH=%LOCALAPPDATA%\Programs\Inno Setup 6\ISCC.exe" +) else ( + for /f "delims=" %%i in ('where ISCC.exe 2^>nul') do set "ISCC_PATH=%%i" +) + +if "%ISCC_PATH%"=="" ( + echo. + echo [WARNUNG] Inno Setup 6 nicht gefunden. Installer wird NICHT erstellt. + echo Download: https://jrsoftware.org/isinfo.php + echo Nach der Installation Inno Setup 6 neu starten und build.bat erneut ausfuehren. + echo. + goto build_done +) + +"%ISCC_PATH%" /DMyAppVersion=%APP_VERSION% installer\AnwesenheitPDF.iss > build_iscc.log 2>&1 + +if %ERRORLEVEL% neq 0 ( + echo. + echo [FEHLER] Installer-Erstellung fehlgeschlagen. + echo. + type build_iscc.log + pause + exit /b 1 +) + +echo. +echo [OK] Installer erstellt: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe + +echo. +echo [5/5] Installer zu update_check_path deployen... + +REM update_check_path aus config.json auslesen +for /f "tokens=2 delims=:," %%d in ('findstr /c:"\"update_check_path\"" config.json') do ( + set UPDATE_PATH=%%d +) +REM Leerzeichen und Anfuehrungszeichen entfernen +set UPDATE_PATH=%UPDATE_PATH: =% +set UPDATE_PATH=%UPDATE_PATH:"=% + +if "%UPDATE_PATH%"=="" ( + echo [WARNUNG] update_check_path nicht in config.json gefunden. Deployment uebersprungen. + goto build_done +) + +REM Zielverzeichnis pruefen und ggf. erstellen +if not exist "%UPDATE_PATH%" ( + echo [INFO] Erstelle Zielverzeichnis: %UPDATE_PATH% + mkdir "%UPDATE_PATH%" + if %ERRORLEVEL% neq 0 ( + echo [WARNUNG] Konnte Zielverzeichnis nicht erstellen. Deployment uebersprungen. + goto build_done + ) +) + +REM Neuen Installer kopieren +echo [INFO] Kopiere Installer nach: %UPDATE_PATH% +copy /Y "dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe" "%UPDATE_PATH%\AnwesenheitPDF-Setup-%APP_VERSION%.exe" >nul + +if %ERRORLEVEL% neq 0 ( + echo [WARNUNG] Installer-Deployment fehlgeschlagen (Schreibrechte?). + goto build_done +) + +echo [OK] Installer deployed zu: %UPDATE_PATH%\AnwesenheitPDF-Setup-%APP_VERSION%.exe + +:build_done echo. echo [OK] Build abgeschlossen. -echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe -echo Template: dist\AnwesenheitPDF\template\sab.pdf -echo Output: dist\AnwesenheitPDF\output +echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe +echo Template: dist\AnwesenheitPDF\template\sab.pdf +echo Output: dist\AnwesenheitPDF\output +if not "%ISCC_PATH%"=="" ( + echo Installer: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe +) +if not "%UPDATE_PATH%"=="" ( + echo Deployed: %UPDATE_PATH%\AnwesenheitPDF-Setup-%APP_VERSION%.exe +) echo. echo ... Programm wird gestartet ... start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe" \ No newline at end of file diff --git a/config.json b/config.json index 17af3bd..bfcc2c9 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,5 @@ { - "version": "1.0.1+250a473", + "version": "1.0.8+47220f9", "antragsnummer": "100740439", "ort": "Dresden", "template_pdf": "template/sab.pdf", @@ -42,5 +42,6 @@ "pauschale_present_value": "1", "pdf_use_display_values": true, "pdf_need_appearances": false, - "excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx" + "excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx", + "update_check_path": "j:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\02_Abrechnung Aufwand und Fahrtgeld\\Automatisierung" } \ No newline at end of file diff --git a/gui.py b/gui.py index 59daf4f..af9495a 100644 --- a/gui.py +++ b/gui.py @@ -2,13 +2,15 @@ # Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten # Build-Artefakte: build/ (temporaer, nicht committen) +import json import os +import re import shutil import sys import threading from datetime import date, datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import customtkinter as ctk import tkinter as tk @@ -99,6 +101,78 @@ def _count_output_pdfs(config: Dict[str, Any]) -> int: return len(list(out_dir.glob("*.pdf"))) +# --------------------------------------------------------------------------- +# Update-check helpers +# --------------------------------------------------------------------------- + +_UPDATE_CHECK_FILE = _runtime_dir() / "update_check.json" +_INSTALLER_PATTERN = re.compile(r"^AnwesenheitPDF-Setup-(\d+\.\d+\.\d+)\.exe$", re.IGNORECASE) + + +def _parse_semver(version_str: str) -> Tuple[int, int, int]: + """Parse semantic version string, ignoring build metadata after '+'.""" + base = version_str.split("+")[0].strip() + match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", base) + if not match: + return (0, 0, 0) + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def _should_check_update() -> bool: + """Return True if no check has been recorded for today.""" + try: + if _UPDATE_CHECK_FILE.exists(): + with open(_UPDATE_CHECK_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("last_check") != date.today().isoformat() + except Exception: + pass + return True + + +def _record_update_check() -> None: + """Persist today's date as the last check timestamp.""" + try: + with open(_UPDATE_CHECK_FILE, "w", encoding="utf-8") as f: + json.dump({"last_check": date.today().isoformat()}, f) + except Exception: + pass + + +def _find_latest_installer(config: Dict[str, Any]) -> Optional[Tuple[str, Path]]: + """Scan the configured SMB update path for the newest installer. + + Returns (version_str, installer_path) if a newer version exists, else None. + """ + update_path_str = str(config.get("update_check_path", "")).strip() + if not update_path_str: + return None + + update_dir = Path(update_path_str) + if not update_dir.exists(): + return None + + current_ver = _parse_semver(str(config.get("version", "0.0.0"))) + best_ver = current_ver + best_file: Optional[Path] = None + best_ver_str: Optional[str] = None + + for f in update_dir.glob("AnwesenheitPDF-Setup-*.exe"): + m = _INSTALLER_PATTERN.match(f.name) + if not m: + continue + ver_str = m.group(1) + ver = _parse_semver(ver_str) + if ver > best_ver: + best_ver = ver + best_file = f + best_ver_str = ver_str + + if best_file and best_ver_str: + return (best_ver_str, best_file) + return None + + def _build_month_keys(config: Dict[str, Any]) -> List[str]: """Return deduplicated month keys from config (preserves insertion order).""" seen: set = set() @@ -178,6 +252,7 @@ class AnwesenheitApp(ctk.CTk): self._tooltip_label: Optional[tk.Label] = None # Build UI sections + self._build_update_banner() self._build_input_section() self._build_button_section() self._build_preview_section() @@ -185,11 +260,68 @@ class AnwesenheitApp(ctk.CTk): # Auto-sync after event loop starts self.after(100, self._start_initial_sync) + # Daily update check + if _should_check_update(): + self.after(200, self._start_update_check) # ------------------------------------------------------------------ # UI builders # ------------------------------------------------------------------ + def _build_update_banner(self) -> None: + """Create the update-available banner (hidden until an update is found).""" + self._update_banner = ctk.CTkFrame(self, fg_color="#854D0E") + # Banner is NOT packed here — only shown when an update is detected + self._update_banner_label = ctk.CTkLabel( + self._update_banner, + text="", + text_color="#FEF9C3", + font=ctk.CTkFont(weight="bold"), + anchor="w", + ) + self._update_banner_label.pack(side="left", padx=12, pady=6, fill="x", expand=True) + self._update_banner_btn = ctk.CTkButton( + self._update_banner, + text="Installieren", + width=110, + height=28, + fg_color="#CA8A04", + hover_color="#A16207", + text_color="white", + command=self._open_update_folder, + ) + self._update_banner_btn.pack(side="right", padx=(0, 12), pady=6) + self._update_installer_path: Optional[Path] = None + + def _show_update_banner(self, version: str, installer: Path) -> None: + """Display the update banner with the new version info.""" + self._update_installer_path = installer + current = _CONFIG.get("version", "?").split("+")[0] + self._update_banner_label.configure( + text=f"Update verfügbar: v{current} → v{version} " + f"({installer.name})" + ) + # Insert banner above everything else + self._update_banner.pack(fill="x", padx=16, pady=(8, 0), before=self.winfo_children()[1]) + + def _open_update_folder(self) -> None: + """Open the installer directory in Explorer.""" + if self._update_installer_path and self._update_installer_path.parent.exists(): + os.startfile(str(self._update_installer_path.parent)) + + def _start_update_check(self) -> None: + threading.Thread(target=self._run_update_check, daemon=True).start() + + def _run_update_check(self) -> None: + try: + result = _find_latest_installer(_CONFIG) + except Exception: + result = None + _record_update_check() + if result: + version, installer = result + self.after(0, self._show_update_banner, version, installer) + def _build_input_section(self) -> None: frame = ctk.CTkFrame(self) frame.pack(fill="x", padx=16, pady=(16, 0)) diff --git a/installer/AnwesenheitPDF.iss b/installer/AnwesenheitPDF.iss new file mode 100644 index 0000000..20ebba1 --- /dev/null +++ b/installer/AnwesenheitPDF.iss @@ -0,0 +1,99 @@ +; Inno Setup Script fuer AnwesenheitPDF +; Compiler: Inno Setup 6.x (https://jrsoftware.org/isinfo.php) +; +; Aufruf durch build.bat: +; iscc.exe /DMyAppVersion=1.0.1 installer\AnwesenheitPDF.iss +; +; Ausgabe: dist\AnwesenheitPDF-Setup-{version}.exe + +#ifndef MyAppVersion + #define MyAppVersion "0.0.0" +#endif + +#define MyAppName "AnwesenheitPDF" +#define MyAppPublisher "SAB Anwesenheitslisten" +#define MyAppExeName "AnwesenheitPDF.exe" +#define MyAppID "{{A3F2C1D7-84BE-4E9A-B5D0-2C7F3A1E9B46}" + +[Setup] +; Eindeutige App-ID – niemals aendern (sonst gelten Updates als Neuinstallation) +AppId={#MyAppID} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppComments=Anwesenheitslisten aus Excel-Daten als PDF generieren + +; Installations-Zielverzeichnis (Programme-Ordner) +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes + +; Installer-Ausgabe +OutputDir=..\dist +OutputBaseFilename=AnwesenheitPDF-Setup-{#MyAppVersion} + +; Komprimierung +Compression=lzma2/ultra64 +SolidCompression=yes +LZMAUseSeparateProcess=yes + +; Erscheinungsbild +WizardStyle=modern +SetupIconFile=..\assets\icon.ico +UninstallDisplayIcon={app}\{#MyAppExeName} + +; Berechtigungen: Admin erforderlich fuer Installation in Programme-Ordner +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog + +; Mindest-Windows-Version: Windows 10 (10.0) +MinVersion=10.0 + +; Installer kann direkt vom Netzlaufwerk (SMB-Share) ausgefuehrt werden +; (kein lokales Entpacken des Installers noetig) +AllowNetworkDrive=yes +AllowUNCPath=yes + +; Versionsinformation fuer die EXE-Eigenschaften +VersionInfoVersion={#MyAppVersion} +VersionInfoCompany={#MyAppPublisher} +VersionInfoDescription={#MyAppName} Setup +VersionInfoProductName={#MyAppName} +VersionInfoProductVersion={#MyAppVersion} + +[Languages] +Name: "german"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +Name: "startmenuicon"; \ + Description: "Startmenü-Einträge erstellen"; \ + GroupDescription: "Startmenü:" + +[Files] +; Gesamten PyInstaller-Output in das Installationsverzeichnis kopieren +Source: "..\dist\AnwesenheitPDF\*"; \ + DestDir: "{app}"; \ + Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +; Startmenü-Eintrag (nur wenn Task ausgewaehlt) +Name: "{group}\{#MyAppName}"; \ + Filename: "{app}\{#MyAppExeName}"; \ + Comment: "Anwesenheitslisten als PDF generieren"; \ + Tasks: startmenuicon + +; Startmenü: Uninstaller (nur wenn Task ausgewaehlt) +Name: "{group}\{#MyAppName} deinstallieren"; \ + Filename: "{uninstallexe}"; \ + Tasks: startmenuicon + +[Run] +; Optionaler Start nach der Installation +Filename: "{app}\{#MyAppExeName}"; \ + Description: "{#MyAppName} jetzt starten"; \ + Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; Beim Deinstallieren: nur generierte Dateien loeschen, keine Nutzerdaten +Type: files; Name: "{app}\*.log" diff --git a/schema.json b/schema.json index 5e6b3a4..b706c41 100644 --- a/schema.json +++ b/schema.json @@ -100,6 +100,10 @@ "excel_network_source": { "type": "string", "description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird" + }, + "update_check_path": { + "type": "string", + "description": "SMB-Pfad zum Verzeichnis mit Installer-Dateien (AnwesenheitPDF-Setup-x.y.z.exe) fuer automatische Update-Pruefung" } } }