Compare commits
5
Commits
102b485794
...
0d5620ae89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d5620ae89 | ||
|
|
8efadb3c79 | ||
|
|
7c629f189a | ||
|
|
d07b4832c6 | ||
|
|
1fb6444444 |
No files matched your search
@@ -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)
|
||||
@@ -13,3 +13,4 @@ tests/fixtures/**/*.xlsx
|
||||
tests/fixtures/**/*.xlsm
|
||||
tests/fixtures/**/reference/*.pdf
|
||||
tests/fixtures/**/diff/
|
||||
*.log
|
||||
@@ -37,6 +37,59 @@ Vor dem ersten Start müssen zwei Werte in `config.json` eingetragen werden:
|
||||
|
||||
Alle anderen Felder sind vorbelegt und müssen nur bei abweichender Excel-Struktur angepasst werden.
|
||||
|
||||
### Erweiterte PDF-Optionen
|
||||
|
||||
Diese Schalter steuern das interne Verhalten beim PDF-Befüllen und müssen in der Regel nicht verändert werden.
|
||||
|
||||
#### `pdf_use_display_values` (Standard: `true`)
|
||||
|
||||
Steuert, welche Werte in Dropdown- und Auswahlfelder geschrieben werden.
|
||||
|
||||
| Wert | Verhalten |
|
||||
|---|---|
|
||||
| `true` | Lesbare Anzeigewerte (z. B. `"X = anwesend"`, `"mind. 6 h"`, `"8,60"`) werden ins PDF geschrieben. Der PDF-Viewer zeigt den Text direkt an. |
|
||||
| `false` | Rohe Export-Werte (z. B. `"X"`, `"6"`, `"1"`) werden verwendet — so wie es das JavaScript im Originalformular intern macht. Sinnvoll, wenn ein externer Viewer (z. B. Adobe Acrobat) die Werte selbst aufzulösen soll. |
|
||||
|
||||
#### `pdf_need_appearances` (Standard: `false`)
|
||||
|
||||
Steuert das `NeedAppearances`-Flag in der erzeugten PDF-Datei.
|
||||
|
||||
| Wert | Verhalten |
|
||||
|---|---|
|
||||
| `false` | Der PDF-Renderer nutzt die im PDF gespeicherten Appearance-Streams (AP) direkt. Buttons, Footer und Summenfelder werden korrekt angezeigt. **Empfohlene Einstellung.** |
|
||||
| `true` | Der PDF-Viewer regeneriert alle Darstellungen aus den Felddefinitionen (`/DA`). Kann helfen, wenn ein externer Viewer einzelne Felder leer anzeigt — kann aber dazu führen, dass Buttons und Footer ausgeblendet werden. |
|
||||
|
||||
> **Hinweis:** Die Summenfelder erhalten beim Generieren eigene Appearance-Streams (Zwei-Pass-Schreibstrategie), sodass `pdf_need_appearances: false` für alle Felder funktioniert.
|
||||
|
||||
#### `regression` (Objekt)
|
||||
|
||||
Einstellungen für den automatisierten Regressions-Bildvergleich (`pytest`). Alle Unterfelder befinden sich in diesem Objekt.
|
||||
|
||||
```json
|
||||
"regression": {
|
||||
"enabled": false,
|
||||
"mask_footer": true
|
||||
}
|
||||
```
|
||||
|
||||
##### `regression.enabled` (Standard: `false`)
|
||||
|
||||
Aktiviert oder deaktiviert den Bildvergleich.
|
||||
|
||||
| Wert | Verhalten |
|
||||
|---|---|
|
||||
| `false` | Regression-Tests werden übersprungen (`pytest.skip`). **Standard.** |
|
||||
| `true` | Bildvergleich wird ausgeführt. Erfordert Referenz-PDFs in `tests/fixtures/`. |
|
||||
|
||||
##### `regression.mask_footer` (Standard: `true`)
|
||||
|
||||
Steuert, ob der untere Bereich (Footer) beim Pixel-Vergleich ignoriert wird.
|
||||
|
||||
| Wert | Verhalten |
|
||||
|---|---|
|
||||
| `true` | Die untere ~20% jeder PDF-Seite wird ignoriert. Sinnvoll, da Template-PDFs je nach Vorlage unterschiedlich viele Footer-Zeilen (2 oder 3) haben können. |
|
||||
| `false` | Vollständiger Pixel-by-Pixel-Vergleich inkl. Footer-Bereich. |
|
||||
|
||||
---
|
||||
|
||||
## Verzeichnisstruktur
|
||||
@@ -198,3 +251,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: <https://jrsoftware.org/isinfo.php>
|
||||
|
||||
### 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 |
|
||||
@@ -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,76 @@ 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...
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $config = Get-Content -Raw -Encoding UTF8 'config.json' | ConvertFrom-Json; $updatePath = $config.update_check_path; if ([string]::IsNullOrWhiteSpace($updatePath)) { Write-Host '[WARNUNG] update_check_path nicht in config.json gefunden. Deployment uebersprungen.'; exit 0 }; $installer = Join-Path (Get-Location) 'dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe'; if (-not (Test-Path -LiteralPath $installer)) { throw ('Installer nicht gefunden: ' + $installer) }; while (-not (Test-Path -LiteralPath $updatePath)) { Write-Host ''; Write-Host ' +----------------------------------------------------------+'; Write-Host ' | [!] Deployment-Pfad nicht erreichbar |'; Write-Host ' +----------------------------------------------------------+'; Write-Host ( ' | Pfad : {0}' -f $updatePath ); Write-Host ' | |'; Write-Host ' | Bitte Netzlaufwerk / Zielpfad verbinden und dann ENTER |'; Write-Host ' | druecken. |'; Write-Host ' +----------------------------------------------------------+'; [void](Read-Host 'Eingabe'); }; Write-Host ('[OK] Deployment-Pfad erreichbar: {0}' -f $updatePath); Copy-Item -LiteralPath $installer -Destination $updatePath -Force; Write-Host ('[OK] Installer deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $installer -Leaf)))"
|
||||
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo.
|
||||
echo [WARNUNG] Installer-Deployment fehlgeschlagen.
|
||||
goto build_done
|
||||
)
|
||||
|
||||
: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
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ... Programm wird gestartet ...
|
||||
start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe"
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.0.1+250a473",
|
||||
"version": "1.0.15+d07b483",
|
||||
"antragsnummer": "100740439",
|
||||
"ort": "Dresden",
|
||||
"template_pdf": "template/sab.pdf",
|
||||
@@ -42,5 +42,10 @@
|
||||
"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"
|
||||
"regression": {
|
||||
"enabled": false,
|
||||
"mask_footer": true
|
||||
},
|
||||
"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\\"
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
+20
@@ -97,9 +97,29 @@
|
||||
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
|
||||
"default": false
|
||||
},
|
||||
"regression": {
|
||||
"type": "object",
|
||||
"description": "Einstellungen fuer den Regressions-Bildvergleich (pytest).",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Aktiviert oder deaktiviert den Regressions-Bildvergleich. false = Tests werden uebersprungen (default).",
|
||||
"default": false
|
||||
},
|
||||
"mask_footer": {
|
||||
"type": "boolean",
|
||||
"description": "Ignoriert den unteren Bereich (Footer) beim Pixel-Vergleich. true = untere 20% maskieren; false = vollstaendiger Vergleich.",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,30 @@ def get_sheet_names(file_path: str | Path) -> List[str]:
|
||||
return names
|
||||
|
||||
|
||||
def resolve_sheet_name(
|
||||
sheet_name: str,
|
||||
workbook_sheets: List[str],
|
||||
month_mapping: Dict[str, str],
|
||||
) -> str:
|
||||
"""Resolve a month alias to the actual sheet name present in the workbook.
|
||||
|
||||
If *sheet_name* exists in *workbook_sheets*, it is returned unchanged.
|
||||
Otherwise all aliases in *month_mapping* that map to the same month number
|
||||
are checked, and the first one present in *workbook_sheets* is returned.
|
||||
If no alias matches, the original *sheet_name* is returned so that the
|
||||
normal missing-sheet error can fire.
|
||||
"""
|
||||
if sheet_name in workbook_sheets:
|
||||
return sheet_name
|
||||
target_month = month_mapping.get(sheet_name)
|
||||
if target_month is None:
|
||||
return sheet_name
|
||||
for alias, month_num in month_mapping.items():
|
||||
if month_num == target_month and alias in workbook_sheets:
|
||||
return alias
|
||||
return sheet_name
|
||||
|
||||
|
||||
def extract_persons(
|
||||
file_path: str | Path,
|
||||
sheet_name: str,
|
||||
@@ -158,6 +182,7 @@ def extract_persons(
|
||||
|
||||
wb = _open_workbook(Path(file_path))
|
||||
try:
|
||||
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, config["month_mapping"])
|
||||
data = _sheet_data(wb, sheet_name)
|
||||
finally:
|
||||
wb.close()
|
||||
@@ -217,6 +242,7 @@ def extract_attendance(
|
||||
|
||||
wb = _open_workbook(Path(file_path))
|
||||
try:
|
||||
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, month_mapping)
|
||||
data = _sheet_data(wb, sheet_name)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
+8
-5
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config_loader import load_config
|
||||
from .excel_reader import resolve_sheet_name
|
||||
from .exceptions import ConfigurationError
|
||||
|
||||
|
||||
@@ -71,18 +72,19 @@ def validate(
|
||||
)
|
||||
sheet_names = wb.sheetnames
|
||||
|
||||
# Check 4: sheet name valid
|
||||
# Check 4: sheet name valid (resolve alias, e.g. Mär → Mrz)
|
||||
if sheet_name not in config["month_mapping"]:
|
||||
errors.append(
|
||||
f"Sheet-Name '{sheet_name}' nicht in month_mapping der config.json."
|
||||
)
|
||||
if sheet_name not in sheet_names:
|
||||
resolved = resolve_sheet_name(sheet_name, sheet_names, config["month_mapping"])
|
||||
if resolved not in sheet_names:
|
||||
errors.append(
|
||||
f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. "
|
||||
f"Verfügbar: {sheet_names}"
|
||||
)
|
||||
else:
|
||||
ws = wb[sheet_name]
|
||||
ws = wb[resolved]
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
|
||||
name_col: int = config["excel_name_column"]
|
||||
@@ -179,11 +181,12 @@ def _extract_samples(
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
if sheet_name not in wb.sheetnames:
|
||||
resolved = resolve_sheet_name(sheet_name, wb.sheetnames, config["month_mapping"])
|
||||
if resolved not in wb.sheetnames:
|
||||
wb.close()
|
||||
return result
|
||||
|
||||
ws = wb[sheet_name]
|
||||
ws = wb[resolved]
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
wb.close()
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ When a page fails the threshold, three images are saved to
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -131,26 +132,46 @@ def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
|
||||
writer.write(fh)
|
||||
|
||||
|
||||
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float:
|
||||
"""Return the fraction of pixels where all RGB channels differ by at most PIXEL_TOLERANCE."""
|
||||
def _compute_similarity(ref: np.ndarray, gen: np.ndarray, mask_footer: bool = False) -> tuple[float, int, int]:
|
||||
"""Return (similarity, different_pixels, total_pixels).
|
||||
|
||||
If mask_footer=True, ignores the bottom ~20% of each image (footer area).
|
||||
"""
|
||||
if ref.shape != gen.shape:
|
||||
gen = _resize_to_match(gen, ref.shape)
|
||||
|
||||
# Apply footer mask if requested
|
||||
if mask_footer:
|
||||
h = ref.shape[0]
|
||||
mask_start = int(h * 0.80) # Ignore bottom 20%
|
||||
ref = ref[:mask_start]
|
||||
gen = gen[:mask_start]
|
||||
|
||||
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
|
||||
matching = np.all(diff <= PIXEL_TOLERANCE, axis=2)
|
||||
return float(matching.mean())
|
||||
similar_count = np.sum(matching)
|
||||
total_count = matching.size
|
||||
|
||||
return (similar_count / total_count, total_count - similar_count, total_count)
|
||||
|
||||
|
||||
def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_label: str) -> None:
|
||||
"""Save reference, generated, and heatmap diff images for a failing page."""
|
||||
def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_label: str, mask_footer: bool = False) -> None:
|
||||
"""Save reference, generated, and heatmap diff images for a failing page.
|
||||
|
||||
If mask_footer=True, the bottom ~20% (footer area) is shown in gray in the diff image.
|
||||
The diff is computed BEFORE masking to show actual differences, then the masked area is grayed out.
|
||||
"""
|
||||
if ref.shape != gen.shape:
|
||||
gen = _resize_to_match(gen, ref.shape)
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save full reference and generated images
|
||||
Image.fromarray(ref).save(str(out_dir / f"{page_label}_reference.png"))
|
||||
Image.fromarray(gen).save(str(out_dir / f"{page_label}_generated.png"))
|
||||
|
||||
# Heatmap: identical areas dimmed, differences highlighted in red
|
||||
# NOTE: diff is computed on FULL images, not masked, to show all actual differences
|
||||
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
|
||||
max_diff_per_pixel = diff.max(axis=2)
|
||||
severity = max_diff_per_pixel / 255.0
|
||||
@@ -162,6 +183,12 @@ def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_labe
|
||||
blended = (base * (1.0 - alpha) + overlay * alpha).astype(np.uint8)
|
||||
blended[max_diff_per_pixel > 30] = [255, 0, 0]
|
||||
|
||||
# Gray out masked footer region in diff image
|
||||
if mask_footer:
|
||||
h = ref.shape[0]
|
||||
mask_start = int(h * 0.99)
|
||||
blended[mask_start:] = (180, 180, 180) # Gray footer region
|
||||
|
||||
Image.fromarray(blended).save(str(out_dir / f"{page_label}_diff.png"))
|
||||
|
||||
|
||||
@@ -181,6 +208,17 @@ def test_pdf_matches_reference(
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
# Load config
|
||||
config_path = project_root / "config.json"
|
||||
with config_path.open() as f:
|
||||
config = json.load(f)
|
||||
regression_cfg = config.get("regression", {})
|
||||
|
||||
if not regression_cfg.get("enabled", False):
|
||||
pytest.skip("Regression-Bildvergleich deaktiviert (regression.enabled=false in config.json)")
|
||||
|
||||
mask_footer = regression_cfg.get("mask_footer", True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Generate PDFs via the CLI into a temporary directory
|
||||
# ------------------------------------------------------------------
|
||||
@@ -252,18 +290,19 @@ def test_pdf_matches_reference(
|
||||
ref_img = ref_images[page_idx]
|
||||
gen_img = gen_images[page_idx]
|
||||
|
||||
similarity = _compute_similarity(ref_img, gen_img)
|
||||
similarity, diff_pixels, total_pixels = _compute_similarity(ref_img, gen_img, mask_footer=mask_footer)
|
||||
|
||||
if similarity < SIMILARITY_THRESHOLD:
|
||||
page_tag = f"seite_{page_idx + 1:02d}"
|
||||
img_dir = diff_dir / clean_stem / page_tag
|
||||
_save_diff_images(
|
||||
ref_img, gen_img, img_dir, page_tag
|
||||
ref_img, gen_img, img_dir, page_tag, mask_footer=mask_footer
|
||||
)
|
||||
rel = img_dir.relative_to(project_root)
|
||||
failures.append(
|
||||
f" {page_label}: Ähnlichkeit {similarity:.1%} "
|
||||
f"(Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
|
||||
f" {page_label}: Ähnlichkeit {similarity:.2%} "
|
||||
f"({diff_pixels:,} / {total_pixels:,} Pixel unterschiedlich, "
|
||||
f"Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
|
||||
)
|
||||
|
||||
if failures:
|
||||
|
||||
Reference in new issue
Block a user