Compare commits

...
8 Commits
14 changed files with 807 additions and 46 deletions

No files matched your search

+39
View File
@@ -61,3 +61,42 @@ Excel-Datei → excel_reader → MonthlyAttendance → pdf_filler → PDF-Datei
| Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "1" | | Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "1" |
| "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | "0" | | "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | "0" |
| Leer | (überspringen) | — | — | — | — | | 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`
## Release Notes
- Aktuelle Versionsnummer aus config.json bestaetigen.
- Release-Notes-Dokument im Projekt-Root anlegen.
- Release-Notes-Datei zusaetzlich zum Installer in `update_check_path` kopieren.
+1
View File
@@ -13,3 +13,4 @@ tests/fixtures/**/*.xlsx
tests/fixtures/**/*.xlsm tests/fixtures/**/*.xlsm
tests/fixtures/**/reference/*.pdf tests/fixtures/**/reference/*.pdf
tests/fixtures/**/diff/ tests/fixtures/**/diff/
*.log
+111
View File
@@ -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. 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 ## Verzeichnisstruktur
@@ -150,6 +203,19 @@ Die deutschen Monatsabkürzungen entsprechen den Tabellenblatt-Namen in der Exce
| `Nov` | November | | `Nov` | November |
| `Dez` | Dezember | | `Dez` | Dezember |
### Sonderfall `P = Praktikum`
Wenn im Excel-Sheet für einen Tag `P` steht, bleibt der Status im PDF `P`, aber die Stundenfelder werden mit 6 Stunden befüllt:
| Feld | Wert |
|---|---|
| `anwesend.N` | `P` |
| `ist.N` | `6,00` |
| `plan.N` | `6` |
| `pauschale_5.N` | `1` |
`P = Praktikum` wird damit wie ein regulärer Anwesenheitstag mit 6 Stunden abgerechnet und zählt auch für die 5-Euro-Pauschale.
--- ---
## Ausgabe ## Ausgabe
@@ -198,3 +264,48 @@ Das Programm prüft vor jeder Generierung:
7. Headerzeile mit `Name, Vorname` gefunden 7. Headerzeile mit `Name, Vorname` gefunden
8. Tagesspalten ab Spaltenindex 9 vorhanden 8. Tagesspalten ab Spaltenindex 9 vorhanden
9. PDF-Template unter `template/sab.pdf` erreichbar 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 |
+13
View File
@@ -0,0 +1,13 @@
# Release Notes
## Version 1.0.17 (2026-05-21)
### Zusammenfassung
- Lokale Excel-Datei waehlbar ueber Dropdown (input/).
- Neuer Update-Button neben dem Dropdown (nur bei neuerer Quelldatei sichtbar).
- Quelle nutzt nun Netzordner mit mehreren Excel-Dateien.
- Dateiauswahl aus Quelle automatisch: aktuelles Jahr bevorzugt, sonst neuestes Jahr.
- Pruefung auf Updates beim Start und danach alle 60 Sekunden.
- Bei fehlender Verbindung: Warnsymbol in GUI.
- Beim Update: Ueberschreib-Hinweis mit Abbruchoption; nach Bestaetigung Download nach input/ und automatische Auswahl.
- Bugfix Praktikum: Tage mit Status `P` werden in der PDF jetzt wie ein 6h-Anwesenheitstag behandelt, inklusive `mind. 6 h` und Pauschale `8,60`.
+71 -4
View File
@@ -1,12 +1,14 @@
@echo off @echo off
REM Build-Script fuer AnwesenheitPDF.exe REM Build-Script fuer AnwesenheitPDF.exe + Windows-Installer
REM REM
REM Ergebnis: REM Ergebnis:
REM build/ - temporaere PyInstaller-Artefakte REM build/ - temporaere PyInstaller-Artefakte
REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten) REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten)
REM + AnwesenheitPDF-Setup-x.y.z.exe (Windows-Installer)
REM REM
REM Voraussetzungen: REM Voraussetzungen:
REM pip install -r requirements-dev.txt REM pip install -r requirements-dev.txt
REM Inno Setup 6 installiert (https://jrsoftware.org/isinfo.php)
cd /d "%~dp0" cd /d "%~dp0"
@@ -14,7 +16,7 @@ echo.
echo === AnwesenheitPDF Build === echo === AnwesenheitPDF Build ===
echo. echo.
echo [1/3] Abhaengigkeiten installieren... echo [1/4] Abhaengigkeiten installieren...
pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [FEHLER] pip install fehlgeschlagen. echo [FEHLER] pip install fehlgeschlagen.
@@ -22,7 +24,7 @@ if %ERRORLEVEL% neq 0 (
exit /b 1 exit /b 1
) )
echo [2/3] Version aktualisieren... echo [2/4] Version aktualisieren...
python update_version.py python update_version.py
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [FEHLER] Version-Update fehlgeschlagen. echo [FEHLER] Version-Update fehlgeschlagen.
@@ -30,7 +32,7 @@ if %ERRORLEVEL% neq 0 (
exit /b 1 exit /b 1
) )
echo [3/3] Build starten... echo [3/4] Build starten...
echo. echo.
python -m PyInstaller ^ 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 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 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 und Release Notes 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'; $releaseNotes = Join-Path (Get-Location) 'RELEASE_NOTES.md'; 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 (Test-Path -LiteralPath $releaseNotes) { Copy-Item -LiteralPath $releaseNotes -Destination $updatePath -Force; Write-Host ('[OK] Release Notes deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $releaseNotes -Leaf))) } else { Write-Host ('[WARNUNG] Release Notes nicht gefunden: {0}' -f $releaseNotes) }"
if %ERRORLEVEL% neq 0 (
echo.
echo [WARNUNG] Installer-Deployment fehlgeschlagen.
goto build_done
)
:build_done
echo. echo.
echo [OK] Build abgeschlossen. echo [OK] Build abgeschlossen.
echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe
echo Template: dist\AnwesenheitPDF\template\sab.pdf echo Template: dist\AnwesenheitPDF\template\sab.pdf
echo Output: dist\AnwesenheitPDF\output echo Output: dist\AnwesenheitPDF\output
if not "%ISCC_PATH%"=="" (
echo Installer: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe
)
echo. echo.
echo ... Programm wird gestartet ... echo ... Programm wird gestartet ...
start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe" start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe"
+7 -2
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.0.1+250a473", "version": "1.0.17+0d5620a",
"antragsnummer": "100740439", "antragsnummer": "100740439",
"ort": "Dresden", "ort": "Dresden",
"template_pdf": "template/sab.pdf", "template_pdf": "template/sab.pdf",
@@ -42,5 +42,10 @@
"pauschale_present_value": "1", "pauschale_present_value": "1",
"pdf_use_display_values": true, "pdf_use_display_values": true,
"pdf_need_appearances": false, "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\\"
} }
+330 -22
View File
@@ -2,13 +2,15 @@
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten # Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
# Build-Artefakte: build/ (temporaer, nicht committen) # Build-Artefakte: build/ (temporaer, nicht committen)
import json
import os import os
import re
import shutil import shutil
import sys import sys
import threading import threading
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
import customtkinter as ctk import customtkinter as ctk
import tkinter as tk import tkinter as tk
@@ -99,6 +101,167 @@ def _count_output_pdfs(config: Dict[str, Any]) -> int:
return len(list(out_dir.glob("*.pdf"))) 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)
_SOURCE_EXCEL_DIR = Path(
r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\30_Teilnehmende\01_Anwesenheit\Anwesenheitsliste"
)
_SOURCE_YEAR_PATTERN = re.compile(r"(20\d{2})")
_SOURCE_CHECK_INTERVAL_MS = 60_000
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 _extract_year_from_excel_name(filename: str) -> Optional[int]:
"""Extract a four-digit year from an Excel filename."""
match = _SOURCE_YEAR_PATTERN.search(filename)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def _choose_source_excel(files: List[Path]) -> Optional[Path]:
"""Pick the most relevant source Excel (current year preferred)."""
if not files:
return None
current_year = date.today().year
with_year: List[Tuple[Path, int]] = []
without_year: List[Path] = []
for file in files:
year = _extract_year_from_excel_name(file.name)
if year is None:
without_year.append(file)
else:
with_year.append((file, year))
current_year_files = [f for f, y in with_year if y == current_year]
if current_year_files:
return max(current_year_files, key=lambda p: p.stat().st_mtime)
if with_year:
newest_year = max(y for _, y in with_year)
newest_year_files = [f for f, y in with_year if y == newest_year]
return max(newest_year_files, key=lambda p: p.stat().st_mtime)
return max(without_year, key=lambda p: p.stat().st_mtime)
def _check_source_excel_update() -> Dict[str, Any]:
"""Check source folder connectivity and whether a newer Excel is available."""
result: Dict[str, Any] = {
"connected": False,
"update_available": False,
"source_file": None,
"target_file": None,
"status": "",
}
try:
if not _SOURCE_EXCEL_DIR.exists():
result["status"] = "Netzlaufwerk nicht erreichbar."
return result
source_files = sorted(_SOURCE_EXCEL_DIR.glob("*.xlsx"), key=lambda p: p.name.lower())
result["connected"] = True
if not source_files:
result["status"] = "Keine Excel-Datei in der Quelle gefunden."
return result
source_file = _choose_source_excel(source_files)
if source_file is None:
result["status"] = "Keine geeignete Excel-Datei in der Quelle gefunden."
return result
target_file = _input_dir() / source_file.name
result["source_file"] = source_file
result["target_file"] = target_file
if (not target_file.exists()) or (source_file.stat().st_mtime > target_file.stat().st_mtime):
result["update_available"] = True
result["status"] = f"Neue Excel verfügbar: {source_file.name}"
return result
result["status"] = f"Excel aktuell: {source_file.name}"
return result
except OSError:
result["connected"] = False
result["status"] = "Netzlaufwerk nicht erreichbar."
return result
def _build_month_keys(config: Dict[str, Any]) -> List[str]: def _build_month_keys(config: Dict[str, Any]) -> List[str]:
"""Return deduplicated month keys from config (preserves insertion order).""" """Return deduplicated month keys from config (preserves insertion order)."""
seen: set = set() seen: set = set()
@@ -174,22 +337,81 @@ class AnwesenheitApp(ctk.CTk):
self._out_dir: Optional[Path] = None self._out_dir: Optional[Path] = None
self._input_files: List[Path] = [] self._input_files: List[Path] = []
self._excel_name_to_path: Dict[str, Path] = {} self._excel_name_to_path: Dict[str, Path] = {}
self._pending_source_excel: Optional[Path] = None
self._tooltip_window: Optional[ctk.CTkToplevel] = None self._tooltip_window: Optional[ctk.CTkToplevel] = None
self._tooltip_label: Optional[tk.Label] = None self._tooltip_label: Optional[tk.Label] = None
# Build UI sections # Build UI sections
self._build_update_banner()
self._build_input_section() self._build_input_section()
self._build_button_section() self._build_button_section()
self._build_preview_section() self._build_preview_section()
self._build_log_section() self._build_log_section()
# Auto-sync after event loop starts # Initial scan + continuous source check
self.after(100, self._start_initial_sync) self.after(100, self._start_source_excel_watch)
# Daily update check
if _should_check_update():
self.after(200, self._start_update_check)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# UI builders # 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: def _build_input_section(self) -> None:
frame = ctk.CTkFrame(self) frame = ctk.CTkFrame(self)
frame.pack(fill="x", padx=16, pady=(16, 0)) frame.pack(fill="x", padx=16, pady=(16, 0))
@@ -216,6 +438,21 @@ class AnwesenheitApp(ctk.CTk):
) )
self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w") self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w")
self._btn_excel_update = ctk.CTkButton(
frame,
text="Update",
width=90,
height=28,
command=self._on_excel_update_click,
)
self._btn_excel_update.grid(row=0, column=3, padx=(0, 6), pady=6, sticky="w")
self._btn_excel_update.grid_remove()
self._lbl_source_warning = ctk.CTkLabel(
frame, text="", width=24, anchor="w", text_color="orange"
)
self._lbl_source_warning.grid(row=0, column=4, padx=(0, 12), pady=6, sticky="w")
# Row 1: Template # Row 1: Template
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid( ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
row=1, column=0, padx=(12, 4), pady=6, sticky="w" row=1, column=0, padx=(12, 4), pady=6, sticky="w"
@@ -425,6 +662,32 @@ class AnwesenheitApp(ctk.CTk):
self._excel_menu.configure(values=[text], state="disabled") self._excel_menu.configure(values=[text], state="disabled")
self._excel_var.set(text) self._excel_var.set(text)
def _set_source_state(
self,
connected: bool,
update_available: bool,
source_file: Optional[Path],
status: str,
) -> None:
self._pending_source_excel = source_file if update_available else None
if not connected:
self._lbl_source_warning.configure(text="⚠")
self._btn_excel_update.grid_remove()
self._set_download_status(status or "Netzlaufwerk nicht erreichbar.", "orange")
return
self._lbl_source_warning.configure(text="")
if update_available and source_file is not None:
self._btn_excel_update.grid()
self._btn_excel_update.configure(state="normal", text="Update")
self._set_download_status(status, "#60A5FA")
return
self._btn_excel_update.grid_remove()
if status:
self._set_download_status(status, "#22C55E")
def _set_download_status(self, text: str, color: str = "#60A5FA") -> None: def _set_download_status(self, text: str, color: str = "#60A5FA") -> None:
self._lbl_download_status.configure(text=text, text_color=color) self._lbl_download_status.configure(text=text, text_color=color)
@@ -571,33 +834,78 @@ class AnwesenheitApp(ctk.CTk):
self._excel_var.set(selected_name) self._excel_var.set(selected_name)
self._excel_path = self._excel_name_to_path[selected_name] self._excel_path = self._excel_name_to_path[selected_name]
def _start_initial_sync(self) -> None: def _start_source_excel_watch(self) -> None:
self._set_excel_loading_state("Lade Excel vom Netzlaufwerk ...") self._scan_and_validate()
self._set_download_status("Datei vom Netzlaufwerk wird geprueft ...") self._start_source_excel_check()
threading.Thread(target=self._run_initial_sync, daemon=True).start()
def _run_initial_sync(self) -> None: def _start_source_excel_check(self) -> None:
threading.Thread(target=self._run_source_excel_check, daemon=True).start()
def _run_source_excel_check(self) -> None:
result = _check_source_excel_update()
self.after(0, self._on_source_excel_check_done, result)
def _on_source_excel_check_done(self, result: Dict[str, Any]) -> None:
self._set_source_state(
connected=bool(result.get("connected")),
update_available=bool(result.get("update_available")),
source_file=result.get("source_file"),
status=str(result.get("status") or ""),
)
self.after(_SOURCE_CHECK_INTERVAL_MS, self._start_source_excel_check)
def _on_excel_update_click(self) -> None:
source_file = self._pending_source_excel
if source_file is None:
return
target_file = _input_dir() / source_file.name
if target_file.exists():
proceed = messagebox.askokcancel(
"Datei überschreiben",
f"Die vorhandene Datei\n{target_file.name}\n"
"wird durch die neuere Datei ersetzt.\n\nFortfahren?",
icon="warning",
parent=self,
)
if not proceed:
return
self._btn_excel_update.configure(state="disabled", text="Lade...")
self._set_download_status(f"Lade {source_file.name} ...", "#60A5FA")
threading.Thread(
target=self._run_source_excel_download,
args=(source_file,),
daemon=True,
).start()
def _run_source_excel_download(self, source_file: Path) -> None:
try: try:
synced_path, status = _sync_network_excel(_CONFIG) target_file = _copy_excel_to_input(source_file)
self.after(0, self._finish_initial_sync, synced_path, status, None) self.after(0, self._on_source_excel_download_done, target_file, None)
except Exception as exc: except Exception as exc:
self.after(0, self._finish_initial_sync, None, "", str(exc)) self.after(0, self._on_source_excel_download_done, None, str(exc))
def _finish_initial_sync( def _on_source_excel_download_done(
self, self,
synced_path: Optional[Path], target_file: Optional[Path],
status: str,
error: Optional[str], error: Optional[str],
) -> None: ) -> None:
self._btn_excel_update.configure(state="normal", text="Update")
if error: if error:
self._set_download_status( self._set_download_status(f"Update fehlgeschlagen: {error}", "red")
f"Netzlaufwerk-Pruefung fehlgeschlagen: {error}", return
"orange",
) if target_file is None:
else: self._set_download_status("Update fehlgeschlagen.", "red")
color = "#22C55E" if synced_path else "orange" return
self._set_download_status(status, color)
self._scan_and_validate(selected_excel=synced_path) self._set_download_status(f"Excel aktualisiert: {target_file.name}", "#22C55E")
self._refresh_excel_dropdown(selected=target_file)
self._on_excel_changed()
self._pending_source_excel = None
self._btn_excel_update.grid_remove()
def _on_excel_changed(self) -> None: def _on_excel_changed(self) -> None:
selected = self._excel_name_to_path.get(self._excel_var.get()) selected = self._excel_name_to_path.get(self._excel_var.get())
+99
View File
@@ -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
View File
@@ -97,9 +97,29 @@
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).", "description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
"default": false "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": { "excel_network_source": {
"type": "string", "type": "string",
"description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird" "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"
} }
} }
} }
+26
View File
@@ -139,6 +139,30 @@ def get_sheet_names(file_path: str | Path) -> List[str]:
return names 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( def extract_persons(
file_path: str | Path, file_path: str | Path,
sheet_name: str, sheet_name: str,
@@ -158,6 +182,7 @@ def extract_persons(
wb = _open_workbook(Path(file_path)) wb = _open_workbook(Path(file_path))
try: try:
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, config["month_mapping"])
data = _sheet_data(wb, sheet_name) data = _sheet_data(wb, sheet_name)
finally: finally:
wb.close() wb.close()
@@ -217,6 +242,7 @@ def extract_attendance(
wb = _open_workbook(Path(file_path)) wb = _open_workbook(Path(file_path))
try: try:
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, month_mapping)
data = _sheet_data(wb, sheet_name) data = _sheet_data(wb, sheet_name)
finally: finally:
wb.close() wb.close()
+11 -1
View File
@@ -59,6 +59,8 @@ def build_field_dict(
datum_field: creation_date.strftime("%d.%m.%Y"), datum_field: creation_date.strftime("%d.%m.%Y"),
} }
internship_hours = f"{plan_threshold:.2f}".replace(".", ",")
qualifying_attendance_count: int = 0
pauschale_5_count: int = 0 pauschale_5_count: int = 0
for idx, entry in enumerate(attendance.entries): for idx, entry in enumerate(attendance.entries):
@@ -68,12 +70,20 @@ def build_field_dict(
if entry.is_present: if entry.is_present:
fields[f"ist.{idx}"] = entry.hours_german() fields[f"ist.{idx}"] = entry.hours_german()
fields[f"plan.{idx}"] = plan_value fields[f"plan.{idx}"] = plan_value
if entry.hours is not None and entry.hours >= plan_threshold:
qualifying_attendance_count += 1
# Pauschale only if actual hours >= planned threshold # Pauschale only if actual hours >= planned threshold
if entry.hours is not None and entry.hours >= plan_threshold: if entry.hours is not None and entry.hours >= plan_threshold:
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
pauschale_5_count += 1 pauschale_5_count += 1
else: else:
fields[f"{pauschale_prefix}.{idx}"] = "0" fields[f"{pauschale_prefix}.{idx}"] = "0"
elif entry.status == "P":
fields[f"ist.{idx}"] = internship_hours
fields[f"plan.{idx}"] = plan_value
qualifying_attendance_count += 1
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
pauschale_5_count += 1
else: else:
fields[f"ist.{idx}"] = "" fields[f"ist.{idx}"] = ""
fields[f"plan.{idx}"] = "" fields[f"plan.{idx}"] = ""
@@ -86,7 +96,7 @@ def build_field_dict(
fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}" fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}"
fields["pauschale_25_sum"] = "0.00" fields["pauschale_25_sum"] = "0.00"
fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}" fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}"
fields["anwesend_sum"] = str(pauschale_5_count) fields["anwesend_sum"] = str(qualifying_attendance_count)
return fields return fields
+8 -5
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from .config_loader import load_config from .config_loader import load_config
from .excel_reader import resolve_sheet_name
from .exceptions import ConfigurationError from .exceptions import ConfigurationError
@@ -71,18 +72,19 @@ def validate(
) )
sheet_names = wb.sheetnames 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"]: if sheet_name not in config["month_mapping"]:
errors.append( errors.append(
f"Sheet-Name '{sheet_name}' nicht in month_mapping der config.json." 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( errors.append(
f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. " f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. "
f"Verfügbar: {sheet_names}" f"Verfügbar: {sheet_names}"
) )
else: else:
ws = wb[sheet_name] ws = wb[resolved]
rows = list(ws.iter_rows(values_only=True)) rows = list(ws.iter_rows(values_only=True))
name_col: int = config["excel_name_column"] name_col: int = config["excel_name_column"]
@@ -179,11 +181,12 @@ def _extract_samples(
try: try:
wb = openpyxl.load_workbook(str(file_path), read_only=True, data_only=True) 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() wb.close()
return result return result
ws = wb[sheet_name] ws = wb[resolved]
rows = list(ws.iter_rows(values_only=True)) rows = list(ws.iter_rows(values_only=True))
wb.close() wb.close()
+23 -3
View File
@@ -128,21 +128,30 @@ class TestAbsentDay:
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["anwesend.0"] == status assert fields["anwesend.0"] == status
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
def test_ist_empty(self, cfg, status): def test_ist_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["ist.0"] == "" assert fields["ist.0"] == ""
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
def test_plan_empty(self, cfg, status): def test_plan_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["plan.0"] == "" assert fields["plan.0"] == ""
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
def test_pauschale_empty(self, cfg, status): def test_pauschale_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["pauschale_5.0"] == "0" assert fields["pauschale_5.0"] == "0"
def test_praktikum_sets_fixed_hours(self, cfg):
fields = build_field_dict(_make_attendance(_absent(4, "P")), cfg, date(2026, 2, 1))
assert fields["ist.0"] == "6,00"
assert fields["plan.0"] == "6"
def test_praktikum_sets_pauschale(self, cfg):
fields = build_field_dict(_make_attendance(_absent(4, "P")), cfg, date(2026, 2, 1))
assert fields["pauschale_5.0"] == "1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Multi-row indexing # Multi-row indexing
@@ -177,6 +186,17 @@ class TestSummaryFields:
# Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not # Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not
assert fields["anwesend_sum"] == "2" assert fields["anwesend_sum"] == "2"
def test_anwesend_sum_counts_praktikum_as_6h(self, cfg):
entries = [_present(1, 6.0), _absent(2, "P"), _present(3, 5.0)]
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
assert fields["anwesend_sum"] == "2"
def test_praktikum_increases_pauschale_sum(self, cfg):
entries = [_absent(1, "P")]
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
assert fields["pauschale_5_sum"] == "8.60"
assert fields["pauschale_sum"] == "8.60"
def test_pauschale_5_sum_euro(self, cfg): def test_pauschale_5_sum_euro(self, cfg):
"""pauschale_5_sum = qualifying days * 8.60 (period decimal for JS).""" """pauschale_5_sum = qualifying days * 8.60 (period decimal for JS)."""
entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)] entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)]
+48 -9
View File
@@ -33,6 +33,7 @@ When a page fails the threshold, three images are saved to
""" """
from __future__ import annotations from __future__ import annotations
import json
import re import re
import subprocess import subprocess
import sys import sys
@@ -131,26 +132,46 @@ def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
writer.write(fh) writer.write(fh)
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float: def _compute_similarity(ref: np.ndarray, gen: np.ndarray, mask_footer: bool = False) -> tuple[float, int, int]:
"""Return the fraction of pixels where all RGB channels differ by at most PIXEL_TOLERANCE.""" """Return (similarity, different_pixels, total_pixels).
If mask_footer=True, ignores the bottom ~20% of each image (footer area).
"""
if ref.shape != gen.shape: if ref.shape != gen.shape:
gen = _resize_to_match(gen, ref.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)) diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
matching = np.all(diff <= PIXEL_TOLERANCE, axis=2) 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: 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.""" """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: if ref.shape != gen.shape:
gen = _resize_to_match(gen, ref.shape) gen = _resize_to_match(gen, ref.shape)
out_dir.mkdir(parents=True, exist_ok=True) 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(ref).save(str(out_dir / f"{page_label}_reference.png"))
Image.fromarray(gen).save(str(out_dir / f"{page_label}_generated.png")) Image.fromarray(gen).save(str(out_dir / f"{page_label}_generated.png"))
# Heatmap: identical areas dimmed, differences highlighted in red # 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)) diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
max_diff_per_pixel = diff.max(axis=2) max_diff_per_pixel = diff.max(axis=2)
severity = max_diff_per_pixel / 255.0 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 = (base * (1.0 - alpha) + overlay * alpha).astype(np.uint8)
blended[max_diff_per_pixel > 30] = [255, 0, 0] 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")) 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 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 # 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] ref_img = ref_images[page_idx]
gen_img = gen_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: if similarity < SIMILARITY_THRESHOLD:
page_tag = f"seite_{page_idx + 1:02d}" page_tag = f"seite_{page_idx + 1:02d}"
img_dir = diff_dir / clean_stem / page_tag img_dir = diff_dir / clean_stem / page_tag
_save_diff_images( _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) rel = img_dir.relative_to(project_root)
failures.append( failures.append(
f" {page_label}: Ähnlichkeit {similarity:.1%} " f" {page_label}: Ähnlichkeit {similarity:.2%} "
f"(Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/" f"({diff_pixels:,} / {total_pixels:,} Pixel unterschiedlich, "
f"Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
) )
if failures: if failures: