32 Commits
Author SHA1 Message Date
Rob 5e2fab7fd3 Template PDF erneuert. Neues und altes PDF sind nun enthalten 2026-11-03 12:15:10 +01:00
Rob 0ec5de50bb build pipeline wartet nun darauf das das changelog aktualisiert wurde 2026-11-03 12:06:05 +01:00
Rob fb191449d1 wording für windows angepasst 2026-11-03 11:58:53 +01:00
Rob 4e3b2237ff Neues PDF wurde geliefert und entspechend für das Programm vorbereitet (siehe README im template folder) 2026-11-03 11:58:32 +01:00
RobandClaude Sonnet 5.5 7de6984ebc build: alle Template-PDFs automatisch in Build und Release aufnehmen
Alle *.pdf im template-Ordner werden ausgeliefert; Dateien mit '_' oder '.'
am Anfang sowie andere Dateitypen werden ignoriert. Veraltete PDFs im
Build-Ordner werden entfernt. Tests entsprechend angepasst.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 11:53:55 +01:00
Rob 2237779e56 add: wenn input folder leer -> hochladen von xlxs Dateien vereinfacht 2026-10-07 21:16:46 +02:00
Rob 7d63063024 build option eingefügt um zu builden ohne die version zu inkrementieren und zu deployen 2026-10-07 20:45:33 +02:00
Rob 2aad2c9d99 CI: build pipeline testet nun immer automatisch vor dem build alle Test. Ausserdem gibts einen deploy-pfad 2026-10-07 20:44:51 +02:00
Rob 971459c9bf fix: Erkennung von bereits laufendem Server und dessen beendung. incl. Tests 2026-10-07 19:49:39 +02:00
Rob 1519b96c7b fix: Unicode Zeichen in Windows terminal 2026-10-07 19:48:00 +02:00
Rob eaffa78e9c Testdaten als fixture hinterlegt 2026-10-07 19:46:36 +02:00
Rob 34730276d7 v1.3 2026-08-11 09:44:51 +02:00
Rob c12b15e021 feat: add version check with message in app window 2026-08-11 09:37:55 +02:00
Rob d027bedcb8 Auswählbare Mitarbeitermonate in separater Multiselektliste hinzugefügt 2026-08-10 19:37:03 +02:00
Rob eb1dd45e57 setup README for dev and end user 2026-08-10 18:19:59 +02:00
Rob 6f1d807f3c copy readme into build 2026-06-30 21:35:48 +02:00
Rob 635323ae50 build version v1.2.1 2026-06-30 21:31:14 +02:00
Rob 2c7d69cf78 gitignore 2026-06-30 21:30:44 +02:00
Rob c9dac7b9b8 add build pipline 2026-06-30 21:28:28 +02:00
Rob 94e245a3a0 move the whole app in subfolder 2026-06-30 15:34:47 +02:00
Rob fa3e99dec0 Stelle Stellenanteil dezimal dar 2026-06-30 15:05:12 +02:00
Rob 8fde588b8f Keine Zentrierung für Tätigkeiten. Doppeltes Datum anzeigen. 2026-06-30 10:52:45 +02:00
Rob 87f8150bd3 empty tasks 2026-06-30 09:50:34 +02:00
Rob b498af39d9 Vergrößere alle Textfelder auf das gesamte Formularfeld und zeichne Text mittig. 2026-06-30 09:50:25 +02:00
Rob 2247c42e81 use static font size over all document texts 2026-06-30 09:43:25 +02:00
Rob 23cb3c35b3 version v1.1 2026-06-24 08:38:50 +02:00
Rob dbbd86ece0 add: changelog zum Plan 2026-06-24 08:35:31 +02:00
Rob 557f987ee1 fix konsole error message on app start 2026-06-24 08:14:54 +02:00
Rob b084120c0d fix: empty field: Qualifikation and Funktion 2026-06-24 08:14:29 +02:00
Rob 9f0e3cc8fc fix: nur die erste Person hatte im Tätigkeitenfeld etwas stehen 2026-06-23 12:40:29 +02:00
Rob 932585882e Merge branch 'pdf-as-template'
# Conflicts:
#	template/Tätigkeitsnachweise Stellenförderung.pdf
2026-06-23 11:54:09 +02:00
Rob 93c31655bd nimn Johannes Datei als xlsx template 2026-06-16 21:21:43 +02:00
52 changed files with 2827 additions and 738 deletions

No files matched your search

+18
View File
@@ -126,6 +126,7 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
| `tests/test_e2e.py` | Tests für PDF-Output anpassen | | `tests/test_e2e.py` | Tests für PDF-Output anpassen |
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert | | `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert | | `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
| `CHANGELOG.md` | Changelog – wird bei jedem Version-Build erweitert |
## Projektstruktur ## Projektstruktur
@@ -154,9 +155,26 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
├── coldrun.py ← CLI Cold-Run ├── coldrun.py ← CLI Cold-Run
├── start.bat ← Windows batch zum Start der App ├── start.bat ← Windows batch zum Start der App
├── README.md ← README für Programmbenutzer ├── README.md ← README für Programmbenutzer
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
└── requirements.txt ← streamlit, openpyxl, PyMuPDF └── requirements.txt ← streamlit, openpyxl, PyMuPDF
``` ```
## Versionierung & Changelog
Immer wenn das Signal zum Build einer neuen Version gegeben wird (z. B. „Build Version X.Y“ oder „neue Version bauen“), wird das **Changelog-Logfile** (`CHANGELOG.md` im Projekt-Root) um die **most recent changes** erweitert. Der Eintrag enthält:
- **Versionsnummer** (z. B. `v1.2.0`)
- **Datum** des Builds
- **Neue Funktionen** (Features)
- **Behobene Fehler** (Bugfixes)
- **Sonstige Änderungen** (z. B. Refactoring, Doku-Updates)
Der Changelog wird im [Keep a Changelog](https://keepachangelog.com/)-Format gepflegt und nach Semantic Versioning (`MAJOR.MINOR.PATCH`) versioniert.
> **Wichtig**: Der Changelog-Eintrag wird **vor** dem eigentlichen Build-Vorgang erstellt, sodass die neue Version dokumentiert ist, bevor der Code ausgeliefert wird.
---
## Verifikation ## Verifikation
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren 1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
-46
View File
@@ -1,47 +1 @@
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App) – PDF-Migration
> **Migration**: Das XLSX-Template war schreibgeschützt und wurde durch ein PDF-Template ersetzt.
> Alle `_Field`-Platzhalter im PDF werden durch echte Daten ersetzt.
## 🔄 Migration: XLSX → PDF Template ✅
- [x] **M1**: `requirements.txt` → `PyMuPDF` hinzufügen
- [x] **M2**: `config/settings.ini` → `filename_pattern` auf `.pdf`-Endung ändern
- [x] **M3**: `config/output-fields.json` → `target_cell` entfernen, `pdf_field` hinzufügen (Mapping source_field → PDF-Platzhalter-Name)
- [x] **M4**: `src/config.py` → `get_available_templates()` auf `.pdf`-Dateien umstellen
- [x] **M5**: `src/validator.py` → `validate_template()` für PDF angepasst (prüft `_Field`-Platzhalter via PyMuPDF)
- [x] **M6**: `src/writer.py` → Komplett neu: PDF-basierte Generierung mit PyMuPDF
- Pre-Scan: Alle `_Field`-Platzhalter im Template-PDF finden (Position, Schriftgröße)
- Für jeden Mitarbeiter: neues PDF, pro Monat eine Seite (Template-Kopie), `_Field`-Texte redacten + ersetzen
- Dateiname: `{nachname}_{vorname}_Tätigkeitsnachweis.pdf`
- [x] **M7**: `app.py` → Template-Validierung für PDF angepasst (Dropdown zeigt `.pdf`, Validierung prüft `_Field`-Marker)
- [x] **M8**: `coldrun.py` → An PDF-Template angepasst
- [x] **M9**: `tests/test_e2e.py` → Tests für PDF-Output umgeschrieben (PyMuPDF statt openpyxl zur Output-Validierung)
- [x] **M10**: `.copilot/PLAN.md` und `.copilot/TASKS.md` aktualisiert
## 🔍 Analyse (abgeschlossen)
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysiert
- [x] **A2**: PDF-Template `template/Tätigkeitsnachweise Stellenförderung.pdf` liegt vor (mit `_Field`-Platzhaltern)
## ⚙️ Konfiguration (migriert)
- [x] **K1**: `config/input-fields.json` — unverändert gültig
- [x] **K2**: `config/output-fields.json` — `target_cell` entfernt, `pdf_field` hinzugefügt
- [x] **K3**: `config/settings.ini` — filename_pattern auf `.pdf`
## 🧱 Core-Module
- [x] **C1**: `requirements.txt` — streamlit, openpyxl, PyMuPDF
- [x] **C2**: `src/__init__.py` + `src/models.py` — `OutputMapping` um `pdf_field` erweitert
- [x] **C3**: `src/config.py` — Template-Suche auf `.pdf` umgestellt
- [x] **C4**: `src/parser.py` — unverändert
- [x] **C5**: `src/validator.py` — `validate_template()` für PDF via PyMuPDF
- [x] **C6**: `src/writer.py` — Komplett neu mit PyMuPDF (redact + insert_text)
## 🖥️ UI & Start
- [x] **U1**: `app.py` — PDF-Template-Validierung angepasst
- [x] **U2**: `start.bat` — unverändert
- [x] **U3**: `coldrun.py` — An PDF angepasst, funktioniert
- [x] **U4**: `README.md` — vorhanden
## ✅ Test
- [x] **T1**: E2E-Test mit realer Input-Datei und PDF-Template → 5/5 Tests bestanden
- [x] **T2**: Edge Cases — Umlaute korrekt im PDF, fehlende Marker (Ort/Datum) werden übersprungen
+66 -7
View File
@@ -1,8 +1,67 @@
input/ ### macOS
output/ # Finder metadata
.venv/
__pycache__/
.DS_Store .DS_Store
.~lock.*#
*.xlsx# # Thumbnails
~*.xlsx ._*
# Custom folder icons
Icon␍
# Volume root files
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
### Windows
# Windows thumbnail cache files
Thumbs.db
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows shortcuts
*.lnk
### Linux
# Backup files
*~
# Temporary files from deleted open files
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder
.Trash-*
# NFS temporary files
.nfs*
### VS Code
# VSCode settings (keep shared configuration)
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
3_Archiv
2_build
# Python-Test- und Build-Artefakte
__pycache__/
.pytest_cache/
+9
View File
@@ -0,0 +1,9 @@
input/
output/
.venv/
__pycache__/
.DS_Store
.~lock.*#
*.xlsx#
~*.xlsx
/versions
+176
View File
@@ -0,0 +1,176 @@
# Changelog
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [1.3.4] – 2026-11-03
### Aktualisiert
- Template PDF erneuert. Neues und altes PDF sind nun enthalten
---
## [1.3.3] – 2026-10-07
### Behoben
- internal development changes
---
## [1.3.2] – 2026-10-07
### Hinzugefügt
- **Input-Dateien hinzufügen**: Bei leerem Input-Ordner können `.xlsx`-Dateien per Drag-and-drop oder Dateiauswahl in den Input-Ordner kopiert werden
- **Input-Ordner öffnen**: Ein Button öffnet den Input-Ordner direkt im Explorer
- **Input-Ordner neu einlesen**: Dateiliste kann über Button aktualisiert werden
### Behoben
- EXE erkennt laufende Entwicklungsinstanz und fragt vor dem Neustart
---
## [1.3.1] – 2026-10-07
### Behoben
- Python script erkennt jetzt laufende Entwicklungsinstanz und fragt vor dem Neustart
---
## [1.3] – 2026-08-11
### Hinzugefügt
- **Monatsauswahl pro Mitarbeiter**: Tätigkeitsnachweise können nun für individuell gewählte Monate erzeugt werden.
- Die Monatsauswahl erscheint rechts neben der Mitarbeiterliste für die zuletzt angeklickte Person.
- Die Auswahl wird in Kurzform direkt hinter dem Namen angezeigt, z. B. `(Jan, Mär, Dez)`.
- Standardmäßig sind die letzten zwei vorhandenen Monate ausgewählt.
- Schaltflächen zum Auswählen bzw. Abwählen aller Monate ergänzen die Monatsliste.
- **Versionsprüfung**: Die App prüft beim Start im zentralen SMB-Verzeichnis auf neuere
`Tätigkeitsnachweis-Generator vX.Y.Z.zip`- und künftig auch `.exe`-Dateien.
- **Versionsanzeige**: Die aktuell installierte Programmversion wird dezent am unteren rechten Rand der App angezeigt.
### Geändert
- **Mitarbeiterauswahl**: Mitarbeiter sind nach dem Einlesen einer Input-Datei nicht mehr standardmäßig ausgewählt.
Die Buttons zum Auswählen bzw. Abwählen aller Mitarbeiter befinden sich direkt über der Mitarbeiterliste.
- **Update-Hinweis**: Bei einer neueren zentralen Version wird ein schwebender Hinweis oben rechts angezeigt,
einschließlich Schließen- und Ordner-Schaltfläche.
---
## [1.2.1] – 2026-06-30
### Behoben
- **Build-Pipeline**: fix broken server start and page call
---
## [1.2] – 2026-06-30
### Hinzugefügt
- **Build-Pipeline** (`1_build-pipeline/`): Skripte zum Erstellen einer standalone `.exe`:
- `build.bat` / `build.py`: Orchestrierter Build mit **Semantic Versioning** – liest aktuelle Version
aus `CHANGELOG.md`, bump't nach Wahl (major/minor/patch) und aktualisiert das Changelog automatisch
- `build.spec`: PyInstaller-Spec-Datei mit allen Hidden Imports für Streamlit, openpyxl und PyMuPDF
- `run.py`: Einstiegspunkt für die gebundelte `.exe` – setzt Pfade via `APP_BASE_DIR`,
startet den Streamlit-Server und öffnet den Browser
- Ausgabe nach `2_build/` mit fertiger Ordnerstruktur (`config/`, `input/`, `template/`, `output/`)
- **`app.py`** unterstützt jetzt `APP_BASE_DIR`-Umgebungsvariable für Pfadauflösung im gebundelten Modus
### Geändert
- **Statische Schriftgröße** (`src/writer.py`): Schriftgröße wird nicht mehr dynamisch aus dem PDF-Template extrahiert,
sondern statisch aus `settings.ini` (`[font]` → `fontsize`) gelesen, einheitlich für alle Felder.
`AppConfig` um `fontsize` erweitert, `config.py` lädt den Wert.
- **Autoskalierung entfernt** (`src/writer.py`): Die automatische Schriftverkleinerung für lange Texte
(`Qualifikation_Field`, `Funktion_Field`) wurde ersatzlos gestrichen – alle Texte haben exakt dieselbe Größe.
- **Vertikale Textzentrierung** (`src/writer.py`): Einzeilige Felder werden jetzt vertikal mittig im
Marker-Rechteck platziert. Mehrzeilige Felder (`Tätigkeiten_Field`, `Dienstreisen_Field`) bleiben
oben ausgerichtet.
- **Doppelte Felder unterstützt** (`src/writer.py`): `Datum_Field` und andere Marker, die mehrfach im
PDF-Template vorkommen, werden jetzt alle mit demselben Wert befüllt.
- **Stellenanteil-Formatierung** (`src/writer.py`): `Stellenanteil in %` wird mit deutschem Dezimalkomma
und zwei Nachkommastellen ausgegeben (z. B. `75` → `75,00`).
---
## [1.1] – 2026-06-24
### Hinzugefügt
- **Streamlit-Web-UI** (`app.py`) mit:
- Dropdown zur Auswahl der Input-Datei (`.xlsx`) aus dem `input/`-Ordner – mit Sofort-Validierung und Anzeige von Mitarbeiteranzahl, Antragsnummern und Einträgen pro Monat
- Dropdown zur Auswahl des PDF-Templates aus dem `template/`-Ordner – mit Sofort-Validierung und Anzeige gefundener `_Field`-Platzhalter
- Mitarbeiter-Multiselect-Liste mit „Alle auswählen“ / „Keine auswählen“-Buttons
- „Tätigkeitsnachweise generieren“-Button mit Fortschrittsanzeige
- Session-State-Caching für Validierung und Parsing (kein erneutes Einlesen bei UI-Reruns)
- Erfolgsmeldung mit Liste generierter Dateien und Button zum Öffnen des Output-Ordners
- **PDF-basierte Generierung** (`src/writer.py`) via PyMuPDF (fitz):
- Pre-Scan aller `_Field`-Platzhalter im Template-PDF über **alle Seiten**
- Automatische Erkennung von Position (Bounding Box) und Schriftgröße jedes Platzhalters
- Redact + Insert-Text-Verfahren: Platzhalter werden geschwärzt und durch echte Daten ersetzt
- Unterstützung für **mehrseitige Templates** (z. B. 2 Seiten pro Monat)
- **Mehrzeilige Textboxen** (`Tätigkeiten_Field`, `Dienstreisen_Field`) mit automatischem Wortumbruch
- **Automatische Schriftgrößen-Anpassung** bei langen Texten in Qualifikation und Funktion (Autoscale)
- Prozentwerte werden als Ganzzahl ohne `%`-Zeichen ausgegeben
- Datumsformat `dd.mm.yyyy` wird automatisch aus dem Monatsfeld (`MM.JJ`) berechnet
- Ort standardmäßig „Dresden“
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.pdf`
- **Input-Parsing** (`src/parser.py`):
- Einlesen von `.xlsx`-Dateien mit openpyxl – ein Sheet pro Monat
- Monats-Sheet-Erkennung per Wildcard-Pattern (`* 20??`)
- Prozentwert-Konvertierung: Excel-Prozentwerte (0.5 → 50) oder Ganzzahlen (50 → 50)
- Automatisches Überspringen leerer Zeilen (Name oder Antragsnummer fehlt)
- Gruppierung der Records nach Mitarbeitername (`group_by_employee`)
- Hilfsfunktionen: `get_unique_antragsnummern`, `get_unique_employees`
- **Validierung** (`src/validator.py`):
- Input-Datei: Prüft Existenz, Spaltenüberschriften (gegen `input-fields.json`), Datenzeilen pro Monats-Sheet
- Input-Daten: Erkennt leere Pflichtfelder pro Person und meldet betroffene Namen
- PDF-Template: Sucht alle `_Field`-Platzhalter im PDF und meldet fehlende
- Nicht-Monats-Sheets (z. B. „Alle Rollen“) werden ignoriert
- **Konfigurationsdateien** (`config/`):
- `settings.ini`: Pfade für Input/Template/Output, Dateinamen-Pattern, Monats-Sheet-Pattern, Skip-Optionen
- `input-fields.json`: Spaltenschema der Input-Tabelle (9 Felder: ID, Kommentar, Antragsnummer, Name, Qualifikation, Funktion, Stellenanteil in %, Monat, Tätigkeit)
- `output-fields.json`: Mapping Input-Feld → `_Field`-Platzhalter im PDF (8 Felder)
- **Datenmodell** (`src/models.py`):
- `EmployeeRecord`: Dataclass für eine Mitarbeiter-Zeile mit `month_index`- und `year`-Properties
- `FieldSchema`, `OutputMapping`, `AppConfig`, `ValidationResult`
- **Cold Run** (`coldrun.py`): Headless-Generierung für die erste Person ohne GUI, automatisch mit erster Input- und Template-Datei
- **Start-Skripte**: `start.bat` (Windows), `start.sh` / `start.command` (macOS/Linux)
- **End-to-End-Tests** (`tests/test_e2e.py`):
- pytest-basiert mit realer Input-Datei (`2025 JM.xlsx`) und realem PDF-Template
- Prüft: 168 Datensätze, 19 Personen, 12 Monate
- Validiert Output-PDF: Seitenanzahl (24 Seiten = 12 × 2), Inhalte (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat, Funktion, Tätigkeit), keine verbliebenen `_Field`-Platzhalter
- **README.md**: Vollständige Dokumentation zu Setup, Bedienung, Konfiguration, Cold Run und Tests
### Geändert
- Template-Format von `.xlsx` auf **PDF** umgestellt (ursprüngliches Excel-Template war schreibgeschützt)
- `config/output-fields.json`: `target_cell` entfernt – Mapping erfolgt jetzt über `_Field`-Platzhalter im PDF
- `src/config.py`: `get_available_templates` filtert jetzt `.pdf`-Dateien
+181
View File
@@ -0,0 +1,181 @@
# Tätigkeitsnachweis-Generator
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
## Setup
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
3. **App starten:**
```bash
streamlit run app.py
```
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
## Cold Run – Headless-Generierung
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
im Finder geöffnet.
```bash
# Kommandozeile
.venv/bin/python coldrun.py
# oder per Doppelklick (macOS)
coldrun.command
```
## Bedienung
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
- Nach Auswahl wird die Datei sofort validiert
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
- Nach Auswahl wird das Template sofort validiert
- Angezeigt wird: Anzahl gefundener Zielfelder
3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter
- Standardmäßig sind alle ausgewählt
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
4. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt
## Konfiguration
Alle Einstellungen liegen im `config/`-Ordner:
### `settings.ini`
- `[paths]` – Ordner für Input, Template, Output
- `[output]` – Dateinamen-Pattern (`{first_name}`, `{last_name}`)
- `[parsing]` – Monats-Sheet-Erkennung, Leerzeilen-Verhalten
### `input-fields.json`
Definiert die Spalten der Input-Tabelle:
- `column`: Spaltenbuchstabe (A, B, C…)
- `field_name`: Exakte Spaltenüberschrift aus der Excel-Datei
- `type`: `string` oder `number`
- `required`: `true` oder `false`
### `output-fields.json`
Mapping von Input-Feldern auf Template-Zellen:
- `source_field`: Exakte Spaltenüberschrift aus `input-fields.json`
- `target_cell`: Zelladresse im Template (z. B. `"C16"`)
- `format`: Optionaler Format-String (z. B. `"%.2f"` für Prozentwerte)
## Erwartetes Input-Format
- Eine `.xlsx`-Datei mit einem Sheet pro Monat (z. B. "Januar 2025", "Februar 2025", …)
- Jedes Sheet hat in Zeile 1 die Spaltenüberschriften (laut `input-fields.json`)
- Daten beginnen ab Zeile 2
- Jede Zeile entspricht einer Mitarbeiter-Funktion-Kombination für diesen Monat
## Output
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
## Tests
Beim Start über `start.bat` oder die neu gebaute EXE erscheint bei einer bereits
laufenden Programminstanz ein Ja/Nein-Dialog. „Ja“ beendet die bisherige Instanz
und startet die neue; „Nein“ lässt die bisherige Instanz geöffnet und bricht den
neuen Start ab. Aktuelle Auswahlen gehen beim Beenden verloren. Ein fremdes
Programm auf Port 8501 wird nicht beendet.
Unter Windows führt `test.bat` alle Tests im Ordner `tests/` aus. Das Skript
verwendet die Projektumgebung `.venv` und installiert bei Bedarf `pytest`.
Per Doppelklick bleibt das Ergebnisfenster geöffnet. Für automatisierte Aufrufe:
```powershell
.\0_application\test.bat --no-pause
```
Dieser Aufruf erfolgt aus dem Projekt-Hauptordner. Bei fehlgeschlagenen Tests
liefert das Skript einen Fehlercode und führt die übrigen Tests trotzdem aus.
Beim Build laufen die Tests automatisch vor der Versionierung und dem Bauen.
Beim Programmstart werden sie nicht ausgeführt.
End-to-End-Tests parsen die feste Testdatei (`tests/fixture/2026 JM.xlsx`) und das
PDF-Template (`template/Tätigkeitsnachweise Stellenförderung.pdf`) und validieren
den gesamten Generierungs-Workflow.
```bash
# Test-Abhängigkeiten installieren
.venv/bin/pip install pytest
# Tests ausführen
.venv/bin/python -m pytest tests -v
```
Die Tests prüfen:
- Vorhandensein der Input- und Template-Dateien
- Korrekte Parsierung (100 Datensätze, 17 Personen)
- Vollständige PDF-Pipeline für die erste Person („Bardaus, Madalina“): fünf Monate
von Januar bis Mai 2026 mit zehn Seiten, Antragsnummer, Name, Stellenanteil,
Monat, Funktion, Tätigkeit, Ort und korrekten Monatsenddaten
- Neustartlogik unter Windows mit simulierten Prozessen
## Build und Deployment unter Windows
Alle folgenden Befehle werden im Projekt-Hauptordner ausgeführt:
```powershell
# Interaktiv: Versionsänderung auswählen
.\1_build-pipeline\build.bat
# Bugfix-Version mit Changelog-Beschreibung
.\1_build-pipeline\build.bat --bump patch --message "Fehler beim Programmstart behoben"
# Ablauf anzeigen, ohne Tests, Build oder Deployment auszuführen
.\1_build-pipeline\build.bat --bump patch --dry-run
# Aktuelle Version mit aktualisiertem Changelog erneut als EXE bauen
.\1_build-pipeline\build.bat --no-bump --no-deploy
# Nur den letzten erfolgreichen Build bereitstellen (kein neuer Build)
.\1_build-pipeline\deploy.bat
# Deployment ohne abschließende Tasteneingabe
.\1_build-pipeline\deploy.bat --no-pause
```
Die Pipeline führt zuerst alle Tests aus. Bei einem Testfehler bleiben Version
und Changelog unverändert; Build und Deployment werden nicht gestartet.
Nach erfolgreichen Tests aktualisiert sie die Version, erstellt die EXE und
legt sie mit Konfiguration, PDF-Template, README und Changelog in `2_build/` ab.
`release.json` hält die Version und Prüfsummen des erfolgreichen Builds fest.
Bei einem Buildfehler wird der Changelog zurückgesetzt.
Danach erzeugt das Deployment `Tätigkeitsnachweis-Generator vX.Y.Z.zip` und
überträgt es an:
```text
j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\
```
Das Ziel muss bereits vorhanden und über VPN/SMB erreichbar sein. Ist es nicht
erreichbar oder fehlen Schreibrechte, bricht das Deployment verständlich ab.
Der lokale Build bleibt erhalten. Nach Wiederherstellung der VPN-Verbindung
kann `deploy.bat` die Bereitstellung ohne erneutes Testen, Bauen oder Erhöhen
der Versionsnummer wiederholen. Das Skript akzeptiert nur einen bestätigten,
unveränderten Build mit `release.json`; ältere Builds müssen einmal neu gebaut
werden.
Das ZIP enthält leere `input/`- und `output/`-Ordner; Arbeitsdateien und
Testdaten werden nicht mit ausgeliefert. Es wird zunächst als temporäre Datei
übertragen, per Prüfsumme geprüft und erst dann unter dem Versionsnamen
sichtbar gemacht. Bestehende Versionen mit anderem Inhalt werden nicht
überschrieben. Ein identisches Paket kann erneut bereitgestellt werden.
Die Neustarttests simulieren Prozesse und Dialogantworten; ein manueller
Starttest der fertigen EXE bleibt vor der Weitergabe sinnvoll.
+11 -57
View File
@@ -1,53 +1,28 @@
# Tätigkeitsnachweis-Generator # Tätigkeitsnachweis-Generator
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten. Programm zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
Die aktuellesten Programmversion find sich immer hier:
"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\"
## Setup Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
3. **App starten:**
```bash
streamlit run app.py
```
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
## Cold Run – Headless-Generierung
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
im Finder geöffnet.
```bash
# Kommandozeile
.venv/bin/python coldrun.py
# oder per Doppelklick (macOS)
coldrun.command
```
## Bedienung ## Bedienung
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner 1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
- Bei leerem Input-Ordner: „Input-Ordner öffnen“ öffnet den Explorer.
- Excel-Dateien können per Drag-and-drop hochgeladen werden. Sie werden in den
Input-Ordner kopiert; danach wird die Dateiliste automatisch neu eingelesen.
- Nach manuellem Kopieren im Explorer „Input-Ordner neu einlesen“ anklicken.
- Nach Auswahl wird die Datei sofort validiert - Nach Auswahl wird die Datei sofort validiert
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat - Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner 2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
- Nach Auswahl wird das Template sofort validiert - Nach Auswahl wird das Template sofort validiert
- Angezeigt wird: Anzahl gefundener Zielfelder - Angezeigt wird: Anzahl gefundener Zielfelder
3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter 3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter
- Standardmäßig sind alle ausgewählt - Standardmäßig ist keiner ausgewählt
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen - Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
4. **"Tätigkeitsnachweise generieren"** klicken 4. **Mitarbeitermonate auswählen** – Multiselect-Liste aller verfügbaren Monate im Dokument. Vorselektiert sind die aktuell letzten beiden Monate.
5. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt - Die generierten Dateien werden im `output/`-Ordner abgelegt
## Konfiguration ## Konfiguration
@@ -84,24 +59,3 @@ Mapping von Input-Feldern auf Template-Zellen:
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt - Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt - Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx` - Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
## Tests
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren
den gesamten Generierungs-Workflow.
```bash
# Test-Abhängigkeiten installieren
.venv/bin/pip install pytest
# Tests ausführen
.venv/bin/python -m pytest tests/test_e2e.py -v
```
Die Tests prüfen:
- Vorhandensein der Input- und Template-Dateien
- Korrekte Parsierung (168 Datensätze, 19 Personen)
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat,
Funktion, Tätigkeit, Ort, Datum)
+559
View File
@@ -0,0 +1,559 @@
"""Tätigkeitsnachweis-Generator – Streamlit App."""
import os
import subprocess
import sys
from pathlib import Path
import streamlit as st
# Projekt-Root zum Python-Path hinzufügen
# Im gebundelten Modus (PyInstaller) zeigt APP_BASE_DIR auf das Verzeichnis der .exe,
# wo config/, input/, template/, output/ liegen. Im Entwicklungsmodus bleibt es None
# und PROJECT_ROOT wird aus __file__ abgeleitet.
_BASE_DIR = os.environ.get("APP_BASE_DIR")
if _BASE_DIR:
PROJECT_ROOT = Path(_BASE_DIR).resolve()
else:
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import (
load_settings,
load_input_schema,
load_output_mapping,
get_available_input_files,
get_available_templates,
)
from src.models import AppConfig
from src.parser import parse_input_file, group_by_employee, get_unique_antragsnummern, get_unique_employees
from src.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports
from version_check import UPDATE_DIR, get_latest_version
def open_input_folder(directory: Path):
directory.mkdir(parents=True, exist_ok=True)
if sys.platform == "win32":
os.startfile(str(directory))
else:
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", str(directory)], check=True)
def set_active_employee(employee_name: str):
"""Merkt die zuletzt angeklickte Person für die Monatsliste."""
st.session_state.active_month_employee = employee_name
def sync_employee_months(employee_name: str, months: list[str]):
"""Übernimmt Monats-Checkboxen vor dem nächsten UI-Rendern."""
st.session_state.employee_months[employee_name] = {
month for month in months
if st.session_state.get(f"month_{employee_name}_{month}", False)
}
def set_all_employee_months(employee_name: str, months: list[str], selected: bool):
"""Setzt alle Monats-Checkboxen einer Person gesammelt."""
st.session_state.employee_months[employee_name] = set(months) if selected else set()
for month in months:
st.session_state[f"month_{employee_name}_{month}"] = selected
# --- Seite konfigurieren ---
st.set_page_config(
page_title="Tätigkeitsnachweis-Generator",
page_icon="📋",
layout="wide",
)
st.title("📋 Tätigkeitsnachweis-Generator")
st.markdown("Erstellt Tätigkeitsnachweise aus Input-Daten und einem Excel-Template.")
# --- Konfiguration laden ---
CONFIG_DIR = PROJECT_ROOT / "config"
@st.cache_data
def load_app_config():
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR)
return config
try:
app_config = load_app_config()
except Exception as e:
st.error(f"Fehler beim Laden der Konfiguration: {e}")
st.stop()
# --- Versionsinformation für die dezente Anzeige am Seitenende ---
current_version_text = ""
try:
local_version, latest_version = get_latest_version()
current_version_text = f"v{'.'.join(map(str, local_version))}"
if latest_version:
available_version, _ = latest_version
version_text = ".".join(map(str, available_version))
st.html(
f'''<style>
.update-notice {{
position: fixed; top: 4rem; right: 1rem; z-index: 10000;
width: 290px; padding: 8px 12px; color: #5f4700;
background: #fff8e1; border: 1px solid #e0b84c; border-radius: 8px;
box-shadow: 0 3px 12px rgba(0, 0, 0, .18); font-size: .85rem;
}}
#update-notice-dismiss {{ display:none; }}
#update-notice-dismiss:checked + .update-notice {{ display:none; }}
body:has(#update-notice-dismiss:checked) div.st-key-update_notice_open {{ display:none!important; }}
.update-notice__actions {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }}
.update-notice__actions--footer {{ min-height:26px; }}
.update-notice__button {{
display:inline-flex; align-items:center; justify-content:center;
width:26px; height:26px; color:#5f4700; background:#fffdf3;
border:1px solid #d9bd66; border-radius:5px; text-decoration:none;
font-size:1rem; line-height:1;
}}
.update-notice__button:hover {{ background:#f6e7b2; }}
div.st-key-update_notice_open {{
position:fixed; top:8.5rem; right:3.75rem; z-index:10001; width:26px;
}}
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
div.st-key-update_notice_open div[data-testid="stButton"] button {{
min-width:200px; min-height:26px; height:26px; padding:0; color:#5f4700;
background:#fffdf3; border:1px solid #d9bd66; border-radius:5px;
}}
</style>
<input id="update-notice-dismiss" type="checkbox">
<aside class="update-notice" role="status">
<div class="update-notice__actions">
<label class="update-notice__button" for="update-notice-dismiss" title="Hinweis schließen">×</label>
</div>
<strong>Neue Version v{version_text} verfügbar</strong>
<div class="update-notice__actions update-notice__actions--footer">
</div>
</aside>'''
)
with st.container(key="update_notice_open"):
if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"):
if sys.platform == "win32":
os.startfile(str(UPDATE_DIR))
except Exception:
pass
# --- Session State initialisieren ---
if "parsed_records" not in st.session_state:
st.session_state.parsed_records = []
if "input_validated" not in st.session_state:
st.session_state.input_validated = False
if "template_validated" not in st.session_state:
st.session_state.template_validated = False
# Caches für Validierung/Parsing – nur bei Dateiwechsel neu ausführen
if "last_input_file" not in st.session_state:
st.session_state.last_input_file = None
if "last_template_file" not in st.session_state:
st.session_state.last_template_file = None
if "cached_val_result" not in st.session_state:
st.session_state.cached_val_result = None
if "cached_tpl_result" not in st.session_state:
st.session_state.cached_tpl_result = None
if "employee_months" not in st.session_state:
st.session_state.employee_months = {}
# --- Layout: zwei Spalten ---
col1, col2 = st.columns(2)
with col1:
st.subheader("1. Input-Datei")
input_files = get_available_input_files(app_config)
for message in st.session_state.pop("input_upload_messages", []):
st.success(message)
for message in st.session_state.pop("input_upload_errors", []):
st.error(message)
if not input_files:
with st.container(border=True, key="input_file_panel"):
st.markdown("**Excel-Datei hinzufügen**")
st.caption("Keine .xlsx-Dateien im Input/-Ordner gefunden.")
input_directory = Path(app_config.input_dir)
uploaded_files = st.file_uploader(
"Excel-Dateien hierher ziehen oder auswählen",
type=["xlsx"], accept_multiple_files=True,
key=f"input_upload_{st.session_state.get('input_upload_generation', 0)}",
label_visibility="collapsed",
)
if uploaded_files:
saved, errors = [], []
for uploaded_file in uploaded_files:
try:
name = uploaded_file.name
if (Path(name).name != name or "/" in name or "\\" in name
or Path(name).suffix.lower() != ".xlsx" or name.startswith("~")):
raise ValueError("Bitte eine .xlsx-Datei mit gültigem Dateinamen auswählen.")
input_directory.mkdir(parents=True, exist_ok=True)
destination = input_directory / name
counter = 1
while True:
try:
with destination.open("xb") as output:
output.write(uploaded_file.getbuffer())
break
except FileExistsError:
counter += 1
destination = input_directory / f"{Path(name).stem} ({counter}).xlsx"
saved.append(f"{destination.name} wurde in den Input-Ordner kopiert.")
except (OSError, ValueError) as exc:
errors.append(f"{uploaded_file.name} konnte nicht übernommen werden: {exc}")
st.session_state.input_upload_messages = saved
st.session_state.input_upload_errors = errors
st.session_state.input_upload_generation = st.session_state.get("input_upload_generation", 0) + 1
st.session_state.last_input_file = None
st.rerun()
st.caption("Dateien hierher ziehen oder auswählen. Sie werden automatisch im Input-Ordner gespeichert.")
folder_action, refresh_action = st.columns(2)
with folder_action:
if st.button("Ordner öffnen", icon="📁", key="open_input_directory", use_container_width=True):
try:
open_input_folder(input_directory)
except (OSError, subprocess.SubprocessError) as exc:
st.error(f"Der Input-Ordner konnte nicht geöffnet werden: {exc}")
with refresh_action:
if st.button("Neu einlesen", icon="🔄", key="refresh_input_directory", use_container_width=True,
help="Dateiliste aktualisieren, nachdem Dateien im Explorer hinzugefügt wurden."):
st.rerun()
selected_input = None
st.session_state.parsed_records = []
st.session_state.input_validated = False
st.session_state.last_input_file = None
else:
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
if len(input_files) == 1:
selected_input = st.selectbox(
"Input-Datei wählen:",
options=input_files,
)
else:
selected_input = st.selectbox(
"Input-Datei wählen:",
options=[""] + input_files,
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
)
if selected_input:
input_path = Path(app_config.input_dir) / selected_input
# Nur validieren/parsen wenn sich die Datei geändert hat
if selected_input != st.session_state.last_input_file:
with st.spinner("Validiere Input-Datei…"):
val_result = validate_input_file(input_path, app_config.input_schema, app_config.month_sheet_pattern)
if val_result.is_valid:
records = parse_input_file(
input_path,
app_config.input_schema,
app_config.month_sheet_pattern,
app_config.skip_empty_name,
app_config.skip_empty_antragsnummer,
)
st.session_state.parsed_records = records
st.session_state.input_validated = True
else:
st.session_state.parsed_records = []
st.session_state.input_validated = False
st.session_state.cached_val_result = val_result
st.session_state.last_input_file = selected_input
else:
val_result = st.session_state.cached_val_result
if not val_result.is_valid:
st.markdown(
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
'background:#fdedec; margin-bottom:12px">'
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
'❌ Validierung fehlgeschlagen</span></div>',
unsafe_allow_html=True,
)
for err in val_result.errors:
st.error(f"🔴 {err}")
st.session_state.input_validated = False
else:
if val_result.warnings:
st.markdown(
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
'background:#fef9e7; margin-bottom:8px">'
'<span style="color:#7d6608; font-weight:bold">'
'⚠️ Hinweise</span></div>',
unsafe_allow_html=True,
)
for warn in val_result.warnings:
st.warning(warn)
# Zeige Zusammenfassung aus gecachten Records
records = st.session_state.parsed_records
employees = get_unique_employees(records)
antragsnummern = get_unique_antragsnummern(records)
st.success(f"✅ {len(records)} Einträge in {val_result.info.get('sheets_with_data', 0)} Monats-Sheets")
pills = " ".join(
f'<span style="display:inline-block; background:#e8f0fe; color:#1a73e8; '
f'border-radius:16px; padding:4px 12px; margin:3px; font-size:0.9em; '
f'white-space:nowrap">{an}</span>'
for an in antragsnummern
)
st.markdown(f"**Antragsnummern:**<br>{pills}", unsafe_allow_html=True)
with col2:
st.subheader("2. Template")
template_files = get_available_templates(app_config)
if not template_files:
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
selected_template = None
else:
# Lock-Files herausfiltern
real_templates = [t for t in template_files if not t.startswith("~")]
if not real_templates:
real_templates = template_files
# Bei nur einem Template: automatisch selektieren (keine Leerauswahl)
if len(real_templates) == 1:
selected_template = st.selectbox(
"Template wählen:",
options=real_templates,
)
else:
selected_template = st.selectbox(
"Template wählen:",
options=[""] + real_templates,
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
)
if selected_template:
# Nur validieren wenn sich das Template geändert hat
if selected_template != st.session_state.last_template_file:
template_path = Path(app_config.template_dir) / selected_template
with st.spinner("Validiere Template…"):
tpl_result = validate_template(template_path, app_config.output_mapping)
st.session_state.cached_tpl_result = tpl_result
st.session_state.last_template_file = selected_template
st.session_state.template_validated = tpl_result.is_valid
else:
tpl_result = st.session_state.cached_tpl_result
if not tpl_result.is_valid:
st.markdown(
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
'background:#fdedec; margin-bottom:12px">'
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
'❌ Validierung fehlgeschlagen</span></div>',
unsafe_allow_html=True,
)
for err in tpl_result.errors:
st.error(f"🔴 {err}")
st.session_state.template_validated = False
else:
st.session_state.template_validated = True
found = tpl_result.info.get("found_fields", 0)
total = tpl_result.info.get("total_fields", 0)
st.success(f"✅ {found}/{total} Platzhalter-Felder gefunden")
if tpl_result.warnings:
st.markdown(
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
'background:#fef9e7; margin-bottom:8px">'
'<span style="color:#7d6608; font-weight:bold">'
'⚠️ Hinweise</span></div>',
unsafe_allow_html=True,
)
for warn in tpl_result.warnings:
st.warning(warn)
# --- Mitarbeiter-Auswahl ---
st.subheader("3. Mitarbeiter auswählen")
if st.session_state.input_validated and st.session_state.parsed_records:
employees = get_unique_employees(st.session_state.parsed_records)
st.caption(f"{len(employees)} Mitarbeiter gefunden – wähle aus, für wen Tätigkeitsnachweise erstellt werden sollen:")
# Monatsbezeichnungen für die kompakte Anzeige hinter dem Namen
month_labels = {
"01": "Jan", "02": "Feb", "03": "Mär", "04": "Apr",
"05": "Mai", "06": "Jun", "07": "Jul", "08": "Aug",
"09": "Sep", "10": "Okt", "11": "Nov", "12": "Dez",
}
all_groups = group_by_employee(st.session_state.parsed_records)
available_months = {}
for emp in employees:
months = sorted({
record.monat.split(".", 1)[0].zfill(2)
for record in all_groups.get(emp, [])
if record.monat and record.monat.split(".", 1)[0].isdigit()
}, key=lambda value: int(value))
available_months[emp] = months
if emp not in st.session_state.employee_months:
st.session_state.employee_months[emp] = set(months[-2:])
# Mitarbeiter- und Monatsliste teilen sich die verfügbare Breite.
employee_col, month_col = st.columns(2)
with employee_col:
employee_btn_col1, employee_btn_col2 = st.columns(2)
if employee_btn_col1.button("☑ Alle auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = True
if employee_btn_col2.button("☐ Keine auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = False
with st.container(border=True):
selected_employees = []
for emp in employees:
key = f"sel_{emp}"
if key not in st.session_state:
st.session_state[key] = False
selected_months = st.session_state.employee_months.get(emp, set())
short_months = ", ".join(month_labels[m] for m in available_months[emp] if m in selected_months)
label = f"{emp} ({short_months})" if short_months else f"{emp} (**keine Monate**)"
if st.checkbox(
label,
key=key,
on_change=set_active_employee,
args=(emp,),
):
selected_employees.append(emp)
with month_col:
selected_for_months = st.session_state.get("active_month_employee")
if selected_for_months not in employees:
selected_for_months = None
month_btn_col1, month_btn_col2 = st.columns(2)
month_btn_col1.button(
"☑ Alle Monate",
use_container_width=True,
disabled=not selected_for_months,
on_click=set_all_employee_months,
args=(selected_for_months, available_months[selected_for_months], True) if selected_for_months else ("", [], True),
)
month_btn_col2.button(
"☐ Keine Monate",
use_container_width=True,
disabled=not selected_for_months,
on_click=set_all_employee_months,
args=(selected_for_months, available_months[selected_for_months], False) if selected_for_months else ("", [], False),
)
with st.container(border=True):
st.caption("Monate für den angeklickten Mitarbeiter")
if selected_for_months:
st.write(selected_for_months)
month_values = {}
for month in available_months[selected_for_months]:
month_key = f"month_{selected_for_months}_{month}"
if month_key not in st.session_state:
st.session_state[month_key] = month in st.session_state.employee_months[selected_for_months]
month_values[month] = st.checkbox(
f"{month_labels[month]} ({month})",
key=month_key,
on_change=sync_employee_months,
args=(selected_for_months, available_months[selected_for_months]),
)
st.session_state.employee_months[selected_for_months] = {
month for month, checked in month_values.items() if checked
}
else:
st.info("Einen Mitarbeiter links auswählen, um seine Monate festzulegen.")
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
else:
selected_employees = []
if not st.session_state.input_validated:
st.info("Bitte zuerst eine gültige Input-Datei auswählen.")
# --- Hilfsfunktion zum Öffnen von Dateien/Ordnern ---
def open_path(path_str: str):
"""Öffnet eine Datei mit dem Standardprogramm oder einen Ordner im Dateimanager.
Funktioniert plattformübergreifend: macOS, Windows, Linux."""
if sys.platform == "win32":
os.startfile(path_str)
elif sys.platform == "darwin":
subprocess.run(["open", path_str])
else:
subprocess.run(["xdg-open", path_str])
# --- Generate Button ---
st.divider()
gen_col1, gen_col2, gen_col3 = st.columns([1, 1, 2])
with gen_col1:
generate_clicked = st.button(
"🚀 Tätigkeitsnachweise generieren",
type="primary",
disabled=not (st.session_state.input_validated and st.session_state.template_validated and selected_employees),
)
if generate_clicked:
if not selected_employees:
st.error("Keine Mitarbeiter ausgewählt.")
else:
# Filtere Records auf selektierte Mitarbeiter
all_groups = group_by_employee(st.session_state.parsed_records)
selected_groups = {
name: [
record for record in all_groups[name]
if record.monat.split(".", 1)[0].zfill(2)
in st.session_state.employee_months.get(name, set())
]
for name in selected_employees
if name in all_groups
}
selected_groups = {name: records for name, records in selected_groups.items() if records}
if not selected_groups:
st.error("Für die ausgewählten Mitarbeiter sind keine Monate ausgewählt.")
st.stop()
template_path = Path(app_config.template_dir) / selected_template
output_dir = Path(app_config.output_dir)
with st.spinner(f"Generiere {len(selected_groups)} Tätigkeitsnachweise…"):
try:
generated = generate_activity_reports(
employee_records=selected_groups,
template_path=template_path,
mapping=app_config.output_mapping,
output_dir=output_dir,
filename_pattern=app_config.filename_pattern,
fontsize=app_config.fontsize,
)
st.session_state.generated_files = generated
except Exception as e:
st.error(f"Fehler bei der Generierung: {e}")
# --- Generierte Dateien anzeigen (aus Session State, übersteht Reruns) ---
if "generated_files" in st.session_state and st.session_state.generated_files:
generated = st.session_state.generated_files
st.success(f"✅ {len(generated)} Tätigkeitsnachweise erstellt:")
for i, fpath in enumerate(generated):
col_file, col_open, col_folder = st.columns([0.78, 0.11, 0.11])
with col_file:
st.markdown(f"📄 `{fpath.name}`")
with col_open:
st.button(
"🔗 Öffnen",
key=f"open_file_{i}",
on_click=open_path,
args=(str(fpath),),
help="Datei mit Standardprogramm öffnen",
use_container_width=True,
)
with col_folder:
st.button(
"📂",
key=f"open_folder_{i}",
on_click=open_path,
args=(str(fpath.parent),),
help="Ordner im Explorer öffnen",
use_container_width=True,
)
if current_version_text:
st.markdown(
f'<div style="text-align:right; color:#888; font-size:0.8em; margin-top:2em;">'
f'Programmversion {current_version_text}</div>',
unsafe_allow_html=True,
)
@@ -1,4 +1,6 @@
@echo off @echo off
chcp 65001 >nul
set "PYTHONUTF8=1"
title Tätigkeitsnachweis-Generator – Cold Run title Tätigkeitsnachweis-Generator – Cold Run
echo ======================================== echo ========================================
echo Cold Run – Headless-Generierung echo Cold Run – Headless-Generierung
View File
File renamed without changes.
+2 -1
View File
@@ -113,6 +113,7 @@ def main():
mapping=config.output_mapping, mapping=config.output_mapping,
output_dir=output_dir, output_dir=output_dir,
filename_pattern=config.filename_pattern, filename_pattern=config.filename_pattern,
fontsize=config.fontsize,
) )
output_file = generated[0] output_file = generated[0]
@@ -122,7 +123,7 @@ def main():
# ── Datei öffnen (macOS) ────────────────────────────────────────── # ── Datei öffnen (macOS) ──────────────────────────────────────────
if sys.platform == "darwin": if sys.platform == "darwin":
print() print()
print("[INFO] Öffne Datei im Finder…") print("[INFO] Öffne Datei im Explorer")
subprocess.run(["open", str(output_file)], check=False) subprocess.run(["open", str(output_file)], check=False)
print() print()
File renamed without changes.
File renamed without changes.
@@ -6,6 +6,10 @@ output_dir = output
[output] [output]
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
[font]
# Statische Schriftgröße (in Punkt) für alle Felder im PDF
fontsize = 9
[parsing] [parsing]
# Sheets whose names match these patterns are treated as monthly data sheets # Sheets whose names match these patterns are treated as monthly data sheets
# Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen"). # Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen").
File renamed without changes.
+117
View File
@@ -0,0 +1,117 @@
"""Stop an existing instance of this application's Windows server."""
import json
import os
import subprocess
import sys
from pathlib import Path
_CONFIRM_SCRIPT = r'''
function Confirm-ServerRestart {
Add-Type -AssemblyName System.Windows.Forms
$answer = [System.Windows.Forms.MessageBox]::Show(
"Es läuft bereits eine Version des Tätigkeitsnachweis-Generators.`n`nSoll das alte Programm beendet und die neue Version gestartet werden?`nDie aktuellen Auswahlen gehen dabei verloren.",
'Tätigkeitsnachweis-Generator',
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Question,
[System.Windows.Forms.MessageBoxDefaultButton]::Button2
)
return $answer -eq [System.Windows.Forms.DialogResult]::Yes
}
'''
def stop_existing_server(port: int = 8501) -> bool:
"""Ask before stopping this app. Return False if the user declines."""
if os.name != "nt":
raise RuntimeError("Der kontrollierte Server-Neustart unterstützt nur Windows.")
app_path = Path(__file__).resolve().with_name("app.py")
app_paths = [str(app_path)]
if getattr(sys, "frozen", False):
# A locally built EXE in 2_build/ must also recognize the sibling
# development application, whose path differs from _MEIPASS/app.py.
exe_dir = Path(sys.executable).resolve().parent
development_app = exe_dir.parent / "0_application" / "app.py"
if exe_dir.name.lower() == "2_build" and development_app.is_file():
app_paths.append(str(development_app))
# Pass paths through the environment, never interpolate them into shell code.
env = dict(os.environ, APP_RESTART_PATH=str(app_path),
APP_RESTART_PATHS=json.dumps(app_paths), APP_RESTART_PID=str(os.getpid()),
APP_RESTART_PORT=str(port),
APP_RESTART_VENV=str(Path(app_path).parent / ".venv" / "Scripts"))
script = _CONFIRM_SCRIPT + r'''
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$ErrorActionPreference = 'Stop'
try {
$port = [int]$env:APP_RESTART_PORT
$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop |
Where-Object { $_.LocalPort -eq $port })
$processIds = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)
$targets = @()
foreach ($processId in $processIds) {
if ($processId -eq [int]$env:APP_RESTART_PID) {
throw "Der aktuelle Prozess belegt bereits Port $port."
}
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId"
if (-not $process) { continue }
$isExe = $process.Name -eq 'Tätigkeitsnachweis-Generator.exe'
$command = $process.CommandLine
$matchesApp = $false
foreach ($appPath in ($env:APP_RESTART_PATHS | ConvertFrom-Json)) {
$appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' +
[regex]::Escape($appPath) + '"?(?:\s|$)'
if ($command -match $appArgument) { $matchesApp = $true }
}
$isDev = $process.Name -match '^(python(?:w)?(?:\d+(?:\.\d+)*)?(?:w)?|streamlit)\.exe$' -and
$command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+' -and
($matchesApp -or
($command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?app\.py"?(?:\s|$)' -and
[IO.Path]::GetDirectoryName($process.ExecutablePath) -eq $env:APP_RESTART_VENV))
if (-not ($isExe -or $isDev)) {
throw "Port $port wird von einem anderen Programm verwendet (PID $processId, $($process.Name)). Bitte manuell prüfen."
}
$targets += $processId
}
if ($targets.Count -gt 0 -and -not (Confirm-ServerRestart)) {
exit 2
}
foreach ($processId in $targets) {
Stop-Process -Id $processId -Force -ErrorAction Stop
}
$deadline = (Get-Date).AddSeconds(10)
do {
$remaining = @(Get-NetTCPConnection -State Listen -ErrorAction Stop |
Where-Object { $_.LocalPort -eq $port })
if ($remaining.Count -eq 0) { break }
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
if ($remaining.Count -gt 0) { throw "Port $port wurde nicht rechtzeitig freigegeben." }
ConvertTo-Json -Compress -InputObject @($targets)
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}
'''
result = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
env=env, capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if result.returncode == 2:
return False
if result.returncode:
raise RuntimeError((result.stderr or "").strip() or "Der bestehende Server konnte nicht beendet werden.")
stopped = json.loads(result.stdout.strip() or "[]")
if stopped:
print(f"[INFO] Vorherige Instanz beendet (PID: {stopped}).", flush=True)
return True
if __name__ == "__main__":
try:
if not stop_existing_server():
print("[INFO] Die bisherige Version bleibt geöffnet. Keine neue Instanz gestartet.")
raise SystemExit(2)
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
print(f"[FEHLER] {exc}")
raise SystemExit(1)
File renamed without changes.
@@ -21,6 +21,7 @@ def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
paths = parser["paths"] if "paths" in parser else {} paths = parser["paths"] if "paths" in parser else {}
output = parser["output"] if "output" in parser else {} output = parser["output"] if "output" in parser else {}
parsing = parser["parsing"] if "parsing" in parser else {} parsing = parser["parsing"] if "parsing" in parser else {}
font = parser["font"] if "font" in parser else {}
return AppConfig( return AppConfig(
input_dir=str(project_root / paths.get("input_dir", "input")), input_dir=str(project_root / paths.get("input_dir", "input")),
@@ -30,6 +31,7 @@ def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
month_sheet_pattern=parsing.get("month_sheet_pattern", "* 20??"), month_sheet_pattern=parsing.get("month_sheet_pattern", "* 20??"),
skip_empty_name=parsing.getboolean("skip_empty_name", True), skip_empty_name=parsing.getboolean("skip_empty_name", True),
skip_empty_antragsnummer=parsing.getboolean("skip_empty_antragsnummer", True), skip_empty_antragsnummer=parsing.getboolean("skip_empty_antragsnummer", True),
fontsize=font.getfloat("fontsize", 11.0),
) )
@@ -63,6 +63,7 @@ class AppConfig:
month_sheet_pattern: str = "* 20??" month_sheet_pattern: str = "* 20??"
skip_empty_name: bool = True skip_empty_name: bool = True
skip_empty_antragsnummer: bool = True skip_empty_antragsnummer: bool = True
fontsize: float = 11.0
@dataclass @dataclass
File renamed without changes.
File renamed without changes.
+83 -83
View File
@@ -67,7 +67,11 @@ def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: s
if source_field == "Monat (MM.JJ)": if source_field == "Monat (MM.JJ)":
return _format_month_for_template(record.monat) return _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %": elif source_field == "Stellenanteil in %":
return record.stellenanteil try:
value = int(float(str(record.stellenanteil).strip()))
return f"{value},00"
except (ValueError, TypeError):
return str(record.stellenanteil)
elif source_field == "Name": elif source_field == "Name":
return employee_name return employee_name
elif source_field == "Antragsnummer": elif source_field == "Antragsnummer":
@@ -101,10 +105,11 @@ def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: s
def _scan_template_fields( def _scan_template_fields(
template_path: Path, template_path: Path,
mapping: list[OutputMapping], mapping: list[OutputMapping],
) -> dict[str, dict]: fontsize: float,
) -> dict[str, list[dict]]:
""" """
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern. Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
Rückgabe: dict[pdf_field] → {source_field, page_num, rect, fontsize, fontname, ...} Rückgabe: dict[pdf_field] → Liste von {source_field, page_num, rect, fontsize, fontname, ...}
Felder mit pdf_field=None werden übersprungen. Felder mit pdf_field=None werden übersprungen.
""" """
doc = fitz.open(template_path) doc = fitz.open(template_path)
@@ -115,43 +120,23 @@ def _scan_template_fields(
if m.pdf_field is not None: if m.pdf_field is not None:
pdf_to_source[m.pdf_field] = m.source_field pdf_to_source[m.pdf_field] = m.source_field
field_info: dict[str, dict] = {} field_info: dict[str, list[dict]] = {}
# 1. Per search_for die Rechtecke aller PDF-Feld-Marker finden (alle Seiten) # Alle Vorkommen aller PDF-Feld-Marker finden (alle Seiten)
for pdf_field, source_field in pdf_to_source.items(): for pdf_field, source_field in pdf_to_source.items():
found_any = False occurrences: list[dict] = []
for page_num in range(doc.page_count): for page_num in range(doc.page_count):
page = doc[page_num] page = doc[page_num]
found = page.search_for(pdf_field) found = page.search_for(pdf_field)
if found: for rect in found:
field_info[pdf_field] = { occurrences.append({
"source_field": source_field, "source_field": source_field,
"page_num": page_num, "page_num": page_num,
"rect": found[0], "rect": rect,
"fontsize": 11, # Fallback "fontsize": fontsize,
"fontname": "helv", "fontname": "helv",
} })
found_any = True field_info[pdf_field] = occurrences
break # erstes Vorkommen (seitenübergreifend)
if not found_any:
field_info[pdf_field] = None # nicht gefunden
# 2. Schriftgrößen aus den Text-Details extrahieren (alle Seiten)
for page_num in range(doc.page_count):
page = doc[page_num]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if "lines" not in block:
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if text.endswith("_Field") and text in field_info:
info = field_info[text]
if info is not None:
info["fontsize"] = span["size"]
info["fontname"] = span.get("font", "helv")
info["rect"] = fitz.Rect(span["bbox"])
doc.close() doc.close()
return field_info return field_info
@@ -166,72 +151,86 @@ def _fill_pdf_pages(
record: EmployeeRecord, record: EmployeeRecord,
employee_name: str, employee_name: str,
mapping: list[OutputMapping], mapping: list[OutputMapping],
field_info: dict[str, dict], field_info: dict[str, list[dict]],
fontsize: float,
) -> None: ) -> None:
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein.""" """Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden # Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"} MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"}
TEXTBOX_FONT_SCALE = 1 # Schriftgrößen-Faktor für Textboxen
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
TEXTBOX_HEIGHT_PT = 120 # Höhe der Textbox in Punkt
for m in mapping: for m in mapping:
if m.pdf_field is None: if m.pdf_field is None:
continue continue
info = field_info.get(m.pdf_field) occurrences = field_info.get(m.pdf_field, [])
if info is None: if not occurrences:
continue # Feld nicht im Template gefunden continue # Feld nicht im Template gefunden
page_num = info["page_num"]
if page_num >= len(pages):
continue
page = pages[page_num]
rect = info["rect"]
fontsize = info.get("fontsize", 11)
value = _get_field_value(record, employee_name, m.source_field) value = _get_field_value(record, employee_name, m.source_field)
formatted = _format_value(value, m.format) formatted = _format_value(value, m.format)
# Platzhalter immer redacten, auch wenn kein Wert zum Einsetzen da ist # Jedes Vorkommen des Feldes im PDF befüllen
page.add_redact_annot(rect) for info in occurrences:
page.apply_redactions() page_num = info["page_num"]
if page_num >= len(pages):
if not formatted:
continue
# Mehrzeilige Felder: insert_textbox mit Zeilenumbruch
if m.pdf_field in MULTILINE_FIELDS:
# Doppelte Leerzeilen entfernen
clean_text = "\n".join(
line for line in formatted.split("\n") if line.strip()
)
if not clean_text:
continue continue
# Rechteck für die Textbox: ausgehend vom Marker, nach rechts & unten erweitert page = pages[page_num]
textbox_rect = fitz.Rect( rect = info["rect"]
rect.x0, # links
rect.y0, # oben # Platzhalter redacten
rect.x0 + TEXTBOX_WIDTH_PT, # rechts page.add_redact_annot(rect)
rect.y0 + TEXTBOX_HEIGHT_PT, # unten page.apply_redactions()
)
page.insert_textbox( if not formatted:
textbox_rect, continue
clean_text,
fontname="helv", # Mehrzeilige Felder: Zeilenweise per insert_text schreiben
fontsize=fontsize * TEXTBOX_FONT_SCALE, if m.pdf_field in MULTILINE_FIELDS:
lineheight=1.5, # ... (unverändert)
align=fitz.TEXT_ALIGN_LEFT, clean_text = "\n".join(
) line for line in formatted.split("\n") if line.strip()
else: )
page.insert_text( if not clean_text:
rect.bl, continue
formatted,
fontname="helv", lines = clean_text.split("\n")
fontsize=fontsize, line_spacing = fontsize * 1.2
)
output_lines: list[str] = []
for line in lines:
max_width = TEXTBOX_WIDTH_PT
words = line.split(" ")
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
text_width = fitz.get_text_length(test_line, fontname="helv", fontsize=fontsize)
if text_width <= max_width or not current_line:
current_line = test_line
else:
output_lines.append(current_line)
current_line = word
if current_line:
output_lines.append(current_line)
y = rect.y0 + fontsize
for current_line in output_lines:
page.insert_text(
(rect.x0, y), current_line,
fontname="helv", fontsize=fontsize,
)
y += line_spacing
else:
# Einzeilige Felder: vertikal zentriert platzieren
mid_y = (rect.y0 + rect.y1) / 2
baseline_y = mid_y + fontsize * 0.35
page.insert_text(
(rect.x0, baseline_y),
formatted,
fontname="helv",
fontsize=fontsize,
)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -244,6 +243,7 @@ def generate_activity_reports(
mapping: list[OutputMapping], mapping: list[OutputMapping],
output_dir: Path, output_dir: Path,
filename_pattern: str, filename_pattern: str,
fontsize: float = 11.0,
) -> list[Path]: ) -> list[Path]:
""" """
Erzeugt für jeden Mitarbeiter ein PDF. Erzeugt für jeden Mitarbeiter ein PDF.
@@ -253,7 +253,7 @@ def generate_activity_reports(
generated_files: list[Path] = [] generated_files: list[Path] = []
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln # Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
field_info = _scan_template_fields(template_path, mapping) field_info = _scan_template_fields(template_path, mapping, fontsize)
for employee_name, records in employee_records.items(): for employee_name, records in employee_records.items():
if not records: if not records:
@@ -276,7 +276,7 @@ def generate_activity_reports(
temp_doc = fitz.open(template_path) temp_doc = fitz.open(template_path)
pages = [temp_doc[i] for i in range(temp_doc.page_count)] pages = [temp_doc[i] for i in range(temp_doc.page_count)]
_fill_pdf_pages(pages, record, employee_name, mapping, field_info) _fill_pdf_pages(pages, record, employee_name, mapping, field_info, fontsize)
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen # Alle modifizierten Seiten ins Ergebnis-PDF einfügen
result_doc.insert_pdf(temp_doc) result_doc.insert_pdf(temp_doc)
+10 -1
View File
@@ -1,4 +1,7 @@
@echo off @echo off
chcp 65001 >nul
set "PYTHONUTF8=1"
cd /d "%~dp0"
title Tätigkeitsnachweis-Generator title Tätigkeitsnachweis-Generator
echo ======================================== echo ========================================
echo Tätigkeitsnachweis-Generator echo Tätigkeitsnachweis-Generator
@@ -43,6 +46,12 @@ echo.
echo [INFO] Starte Tätigkeitsnachweis-Generator... echo [INFO] Starte Tätigkeitsnachweis-Generator...
echo [INFO] Die App öffnet sich im Browser unter http://localhost:8501 echo [INFO] Die App öffnet sich im Browser unter http://localhost:8501
echo. echo.
streamlit run app.py ".venv\Scripts\python.exe" server_lifecycle.py
if %ERRORLEVEL% equ 2 exit /b 0
if %ERRORLEVEL% neq 0 (
pause
exit /b 1
)
".venv\Scripts\python.exe" -m streamlit run "%~dp0app.py" --server.port=8501 --server.address=localhost
pause pause
View File
File renamed without changes.
View File
File renamed without changes.
@@ -0,0 +1,34 @@
# Aktualisieren der PDF Datei
Wenn sich die PDF-Datei welche als Vorlagen genutzt wird geändert hat, kann diese nicht direkt vom Programm verwendet werden, da es die zu verwendenden Felder nicht erkennen kann. Wir müssen ihm kurz zeigen wo die zu nutzenden Felder sind.
Öffne Dazu eine bereits bestehende *.odg Datei im Ordner template und schau Dir die Textfelder an, welche in die entsprechenden Felder gelegt, damit das Programm diese später erkennen kann.
Öffne nun das neue PDF mit dem Programm Libre Office Writer und erzeuge in jedem Formularfeld ein Textfeld mit dem entspechenden Feldnamen wie im Beispiel-odg. Die Felder heißen einfach so wie sie im PDF genutzt werden plus Unterstrich Field. Also:
- Antragsnummer_Field
- Name_Field
- Qulifikation_Field
- Funktion_Field
- Stellenanteil_Field
- Monat_Field
In das Feld Tätigkeiten muss kein neues Textfeld eingefügt werden, weil es schon ein Textfeld ist, daher kann in dieses direkt
- Tätigkeiten_Field
eingetragen werden.
Auf Seite zwei muss nur ein Feld
- Datum_Field
erzeugt werden. Denn dieses kann einfach kopiert und im zweiten Datumfeld plaziert werden.
Für die beiden Ortsfelder muss auch jeweils ein Textfeld eingefügt werden, dieses enthält aber einfach den Text:
- Dresden
da es vom Programm nicht geändert wird
# Styling
Um einen einheitlichen look zu garantieren müssen alle eingefügten Textfelder wie folgt gestyled sein:
- das Textfeld selber füllt das komplette Feld bis an alle 4 Ränder aus
- Textgröße: 12 pt
- Font/Schriftart: Arial
- Absatz Textausrichtung: mittig
+45
View File
@@ -0,0 +1,45 @@
@echo off
chcp 65001 >nul
setlocal
set "PYTHONUTF8=1"
cd /d "%~dp0"
title Taetigkeitsnachweis-Generator - Tests
echo ========================================
echo Alle Tests ausfuehren
echo ========================================
echo.
if not exist ".venv\Scripts\python.exe" (
echo [FEHLER] Die virtuelle Umgebung fehlt. Bitte zuerst start.bat ausfuehren.
set "TEST_EXIT_CODE=1"
goto finish
)
".venv\Scripts\python.exe" -c "import pytest" >nul 2>nul
if errorlevel 1 (
echo [INFO] Installiere pytest in der Projektumgebung...
".venv\Scripts\python.exe" -m pip install pytest
if errorlevel 1 (
echo [FEHLER] pytest konnte nicht installiert werden.
set "TEST_EXIT_CODE=1"
goto finish
)
)
REM Keine Abbruchoption: Auch nach fehlgeschlagenen Tests laeuft die Suite weiter.
REM Ohne Cache funktioniert der Testlauf auch bei gesperrtem pytest-Cache.
".venv\Scripts\python.exe" -m pytest tests -v -p no:cacheprovider
set "TEST_EXIT_CODE=%ERRORLEVEL%"
echo.
if "%TEST_EXIT_CODE%"=="0" (
echo [OK] Alle Tests bestanden.
) else (
echo [FEHLER] Testlauf nicht erfolgreich. Details stehen oben.
)
:finish
REM Fuer automatisierte Aufrufe: test.bat --no-pause
if /I not "%~1"=="--no-pause" pause
exit /b %TEST_EXIT_CODE%
+1 -1
View File
@@ -27,7 +27,7 @@ echo ""
echo "[INFO] Führe End-to-End-Tests aus…" echo "[INFO] Führe End-to-End-Tests aus…"
echo "" echo ""
"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v "$VENV_DIR/bin/python" -m pytest tests -v
echo "" echo ""
read -p "Drücke Enter zum Beenden..." read -p "Drücke Enter zum Beenden..."
File renamed without changes.
Binary file not shown.
+191
View File
@@ -0,0 +1,191 @@
"""Verify pipeline gates and deployment against temporary local directories."""
import importlib.util
from pathlib import Path
import sys
from unittest.mock import patch, Mock
import zipfile
import pytest
PIPELINE_DIR = Path(__file__).resolve().parents[2] / "1_build-pipeline"
sys.path.insert(0, str(PIPELINE_DIR))
import deploy
spec = importlib.util.spec_from_file_location("build_pipeline", PIPELINE_DIR / "build.py")
build = importlib.util.module_from_spec(spec)
spec.loader.exec_module(build)
TEMPLATE_NAMES = ["template/A Vorlage.pdf", "template/B Vorlage.PDF"]
IGNORED_NAMES = ["template/_alt.pdf", "template/.versteckt.pdf", "template/A Vorlage.odg"]
@pytest.fixture
def release(tmp_path):
directory = tmp_path / "build"
directory.mkdir()
for name in deploy.PACKAGE_FILES + TEMPLATE_NAMES:
path = directory / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"test release payload")
deploy.write_release_manifest(directory, "1.3")
target = tmp_path / "share"
target.mkdir()
return directory, target
def test_deployment_packages_release_without_work_files(release):
directory, target = release
for name in IGNORED_NAMES:
(directory / name).write_bytes(b"ignored")
for folder in ["input", "output"]:
(directory / folder).mkdir()
(directory / folder / "private.xlsx").write_bytes(b"private")
result = deploy.deploy(directory, target)
assert result.name == "Tätigkeitsnachweis-Generator v1.3.0.zip"
with zipfile.ZipFile(result) as archive:
assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + TEMPLATE_NAMES + ["input/", "output/"])
assert archive.testzip() is None
assert not list(target.glob(".upload-*"))
# An unchanged release can be retried without overwriting the published ZIP.
original_time = result.stat().st_mtime_ns
assert deploy.deploy(directory, target) == result
assert result.stat().st_mtime_ns == original_time
def test_missing_vpn_target_is_not_created(release):
directory, target = release
missing = target / "missing-share"
with pytest.raises(OSError, match="VPN"):
deploy.deploy(directory, missing)
assert not missing.exists()
assert not list(directory.glob("*.zip"))
def test_changed_payload_is_not_deployed(release):
directory, target = release
(directory / deploy.EXE_NAME).write_bytes(b"different executable")
with pytest.raises(ValueError, match="verändert"):
deploy.deploy(directory, target)
assert not list(target.iterdir())
def test_unconfirmed_old_build_is_not_deployed(release):
directory, target = release
(directory / deploy.MANIFEST_NAME).unlink()
with pytest.raises(ValueError, match="bestätigter Build"):
deploy.deploy(directory, target)
def test_existing_different_release_is_preserved(release):
directory, target = release
existing = target / "Tätigkeitsnachweis-Generator v1.3.0.zip"
existing.write_bytes(b"already published")
with pytest.raises(FileExistsError, match="anderem Inhalt"):
deploy.deploy(directory, target)
assert existing.read_bytes() == b"already published"
def test_interrupted_upload_is_not_published(release):
directory, target = release
with patch.object(deploy.shutil, "copyfile", side_effect=OSError("VPN disconnected")):
with pytest.raises(OSError, match="lokale Build bleibt erhalten"):
deploy.deploy(directory, target)
assert not list(target.iterdir())
assert list(directory.glob("*.zip"))
assert deploy.validate_release(directory) == "1.3.0"
def test_corrupt_upload_is_not_published(release):
directory, target = release
def corrupt_copy(source, destination):
Path(destination).write_bytes(b"corrupted")
with patch.object(deploy.shutil, "copyfile", side_effect=corrupt_copy):
with pytest.raises(OSError):
deploy.deploy(directory, target)
assert not list(target.iterdir())
@pytest.fixture
def pipeline(tmp_path, monkeypatch):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text("## [1.3]\n\nOriginal release\n", encoding="utf-8")
version_file = tmp_path / ".last_version"
version_file.write_text("1.3", encoding="utf-8")
monkeypatch.setattr(build, "CHANGELOG_PATH", changelog)
monkeypatch.setattr(build, "VERSION_FILE", version_file)
monkeypatch.setattr(build, "BUILD_OUTPUT_DIR", tmp_path)
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--message", "Fix"])
events = []
names = ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
mocks = {}
for name in names:
mocks[name] = Mock(side_effect=lambda *args, _name=name: events.append(_name))
monkeypatch.setattr(build, name, mocks[name])
return changelog, version_file, events, mocks
def test_pipeline_tests_build_then_deploy(pipeline):
changelog, version_file, events, mocks = pipeline
build.main()
assert events == ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
assert version_file.read_text() == "1.3.1"
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
def test_failing_tests_prevent_version_change_build_and_deploy(pipeline):
changelog, version_file, events, mocks = pipeline
original = changelog.read_bytes()
mocks["_run_tests"].side_effect = SystemExit(1)
with pytest.raises(SystemExit):
build.main()
assert changelog.read_bytes() == original
assert version_file.read_text() == "1.3"
mocks["_run_pyinstaller"].assert_not_called()
mocks["deploy"].assert_not_called()
def test_build_failure_rolls_back_changelog_and_prevents_deployment(pipeline):
changelog, version_file, events, mocks = pipeline
original = changelog.read_text(encoding="utf-8")
mocks["_run_pyinstaller"].side_effect = SystemExit(1)
with pytest.raises(SystemExit):
build.main()
assert changelog.read_text(encoding="utf-8") == original
assert version_file.read_text() == "1.3"
mocks["deploy"].assert_not_called()
mocks["_cleanup"].assert_called_once()
def test_deploy_failure_keeps_successful_build_version(pipeline):
changelog, version_file, events, mocks = pipeline
mocks["deploy"].side_effect = OSError("VPN unavailable")
with pytest.raises(SystemExit):
build.main()
assert version_file.read_text() == "1.3.1"
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
mocks["write_release_manifest"].assert_called_once()
def test_dry_run_does_not_test_build_or_deploy(pipeline, monkeypatch):
changelog, version_file, events, mocks = pipeline
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--dry-run"])
build.main()
assert not events
assert version_file.read_text() == "1.3"
def test_rebuild_preserves_changelog_and_version(pipeline, monkeypatch):
changelog, version_file, events, mocks = pipeline
original = changelog.read_bytes()
monkeypatch.setattr(sys, "argv", ["build.py", "--no-bump", "--no-deploy"])
build.main()
assert changelog.read_bytes() == original
assert version_file.read_text() == "1.3"
mocks["write_release_manifest"].assert_called_once_with(build.BUILD_OUTPUT_DIR, "1.3")
mocks["_run_tests"].assert_called_once()
mocks["_run_pyinstaller"].assert_called_once()
mocks["deploy"].assert_not_called()
+121
View File
@@ -0,0 +1,121 @@
"""End-to-end checks using the fixed 2026 workbook and the PDF template."""
import sys
from pathlib import Path
import fitz
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import load_settings, load_input_schema, load_output_mapping
from src.parser import parse_input_file, group_by_employee, get_unique_employees
from src.writer import generate_activity_reports
CONFIG_DIR = PROJECT_ROOT / "config"
INPUT_FILE = Path(__file__).resolve().parent / "fixture" / "2026 JM.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "06-2026 Tätigkeitsnachweise Stellenförderung.pdf"
@pytest.fixture(scope="module")
def app_config():
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR)
return config
@pytest.fixture(scope="module")
def parsed_records(app_config):
assert INPUT_FILE.is_file(), f"Testdatei nicht gefunden: {INPUT_FILE}"
return parse_input_file(
filepath=INPUT_FILE,
schema=app_config.input_schema,
month_sheet_pattern=app_config.month_sheet_pattern,
skip_empty_name=app_config.skip_empty_name,
skip_empty_antragsnummer=app_config.skip_empty_antragsnummer,
)
@pytest.fixture
def output_dir(tmp_path):
out = tmp_path / "output"
out.mkdir()
return out
class TestEndToEndFirstPerson:
def test_input_file_exists(self):
assert INPUT_FILE.is_file(), f"Testdatei nicht gefunden: {INPUT_FILE}"
def test_template_file_exists(self):
assert TEMPLATE_FILE.is_file(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
def test_template_has_field_markers(self, app_config):
with fitz.open(TEMPLATE_FILE) as doc:
missing = [
mapping.pdf_field
for mapping in app_config.output_mapping
if mapping.pdf_field is not None
and not any(page.search_for(mapping.pdf_field) for page in doc)
]
assert not missing, f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
def test_parse_real_input(self, parsed_records):
# Fixed expectations for tests/fixture/2026 JM.xlsx.
assert len(parsed_records) == 100
employees = get_unique_employees(parsed_records)
assert len(employees) == 17
assert employees[0] == "Bardaus, Madalina"
first_records = group_by_employee(parsed_records)[employees[0]]
assert [record.monat for record in first_records] == [
"01.2026", "02.2026", "03.2026", "04.2026", "05.2026"
]
for record in first_records:
assert record.antragsnummer == "100781516"
assert record.funktion == "Psychologin"
assert record.stellenanteil == "50"
assert record.qualifikation == ""
assert record.taetigkeit.startswith("Psychologische Betreuung")
def test_full_pipeline_first_person(self, app_config, parsed_records, output_dir):
grouped = group_by_employee(parsed_records)
assert len(grouped) == 17
first_person = sorted(grouped)[0]
assert first_person == "Bardaus, Madalina"
assert len(grouped[first_person]) == 5
generated = generate_activity_reports(
employee_records={first_person: grouped[first_person]},
template_path=TEMPLATE_FILE,
mapping=app_config.output_mapping,
output_dir=output_dir,
filename_pattern=app_config.filename_pattern,
fontsize=app_config.fontsize,
)
assert len(generated) == 1
output_file = generated[0]
assert output_file.is_file()
assert output_file.name == "Bardaus_Madalina_Tätigkeitsnachweis.pdf"
with fitz.open(output_file) as doc:
assert doc.page_count == 10 # Five months, two pages per month.
assert "_Field" not in "".join(page.get_text() for page in doc)
# Independently specified month ends include February's 28 days.
expected_months = [
("01.2026", "31.01.2026"),
("02.2026", "28.02.2026"),
("03.2026", "31.03.2026"),
("04.2026", "30.04.2026"),
("05.2026", "31.05.2026"),
]
for index, (month, month_end) in enumerate(expected_months):
data_text = doc[index * 2].get_text()
signature_text = doc[index * 2 + 1].get_text()
for value in [
"100781516", "Bardaus, Madalina", "50,00", month,
"Psychologin", "Psychologische Betreuung",
]:
assert value in data_text, f"{value!r} fehlt für {month}"
assert "100781516" not in signature_text
assert "Dresden" in signature_text
assert month_end in signature_text
@@ -0,0 +1,192 @@
"""Exercise the actual PowerShell logic with simulated Windows processes."""
import os
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import server_lifecycle
@unittest.skipUnless(os.name == "nt", "Windows process management")
class ServerLifecycleTests(unittest.TestCase):
def simulate(self, process, listening=True, stop_error=False, confirm=True, frozen=False):
captured = {}
def capture(command, **kwargs):
captured.update(script=command[-1], env=kwargs["env"])
return subprocess.CompletedProcess(command, 0, "[]", "")
root = Path(__file__).resolve().parents[2]
with patch.object(server_lifecycle.subprocess, "run", side_effect=capture), \
patch.object(server_lifecycle.sys, "frozen", frozen, create=True), \
patch.object(server_lifecycle.sys, "executable", str(root / "2_build" / "Tätigkeitsnachweis-Generator.exe")), \
patch.object(server_lifecycle, "__file__", str(root / "_MEI-test" / "server_lifecycle.py") if frozen else server_lifecycle.__file__):
server_lifecycle.stop_existing_server()
setup = r'''
$script:occupied = LISTENING
function Get-NetTCPConnection {
param($State, $ErrorAction)
if ($script:occupied) { [pscustomobject]@{ LocalPort=8501; OwningProcess=12345 } }
}
function Get-CimInstance {
param($ClassName, $Filter)
PROCESS
}
function Stop-Process {
param($Id, [switch]$Force, $ErrorAction)
[Console]::Error.WriteLine('STOP_CALLED')
STOP
$script:occupied = $false
}
'''.replace("LISTENING", "$true" if listening else "$false")
setup = setup.replace("PROCESS", process).replace(
"\n STOP\n", "\n throw 'simulated access denied'\n" if stop_error else "\n")
confirm_mock = """
function Confirm-ServerRestart {
[Console]::Error.WriteLine('CONFIRM_CALLED')
return ANSWER
}
""".replace("ANSWER", "$true" if confirm else "$false")
script = captured["script"].replace(server_lifecycle._CONFIRM_SCRIPT, confirm_mock)
command = [
"powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
setup + script,
]
# Explicit pipes keep both outputs available independently of pytest's
# own console capture. communicate() drains both pipes concurrently.
with subprocess.Popen(
command, env=captured["env"], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, errors="replace",
) as child:
try:
stdout, stderr = child.communicate(timeout=30)
except subprocess.TimeoutExpired:
child.kill()
child.communicate()
raise
self.assertIsInstance(stdout, str, "stdout wurde nicht erfasst")
self.assertIsInstance(stderr, str, "stderr wurde nicht erfasst")
return subprocess.CompletedProcess(command, child.returncode, stdout, stderr)
def test_free_port(self):
result = self.simulate("throw 'should not inspect a process'", listening=False)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "[]")
self.assertNotIn("CONFIRM_CALLED", result.stderr)
def test_previous_exe_is_stopped(self):
result = self.simulate(
"[pscustomobject]@{ Name='Tätigkeitsnachweis-Generator.exe'; CommandLine=''; ExecutablePath='' }")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "[12345]")
self.assertIn("CONFIRM_CALLED", result.stderr)
self.assertIn("STOP_CALLED", result.stderr)
def test_declining_preserves_previous_instance(self):
result = self.simulate(
"[pscustomobject]@{ Name='Tätigkeitsnachweis-Generator.exe'; CommandLine=''; ExecutablePath='' }",
confirm=False)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("CONFIRM_CALLED", result.stderr)
self.assertNotIn("STOP_CALLED", result.stderr)
def test_declining_returns_false_to_launcher(self):
with patch.object(server_lifecycle.subprocess, "run", return_value=
subprocess.CompletedProcess([], 2, "", "")):
self.assertFalse(server_lifecycle.stop_existing_server())
def test_development_absolute_path_is_stopped(self):
result = self.simulate(r'''[pscustomobject]@{
Name='python.exe'; ExecutablePath='C:\Python\python.exe';
CommandLine=('python -m streamlit run "' + $env:APP_RESTART_PATH + '"')
}''')
self.assertEqual(result.returncode, 0, result.stderr)
def test_legacy_development_start_is_stopped(self):
result = self.simulate(r'''[pscustomobject]@{
Name='python.exe'; CommandLine='streamlit.exe run app.py';
ExecutablePath=($env:APP_RESTART_VENV + '\python.exe')
}''')
self.assertEqual(result.returncode, 0, result.stderr)
def test_versioned_python_prompts_before_stopping(self):
# Windows Store Python reports the real interpreter name even when
# launched through the venv's python.exe.
process = r'''[pscustomobject]@{
Name='python3.13.exe';
ExecutablePath='C:\Program Files\WindowsApps\Python\python3.13.exe';
CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' +
$env:APP_RESTART_PATH + '" --server.port=8501 --server.address=localhost')
}'''
with self.subTest(answer="yes"):
result = self.simulate(process, confirm=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("CONFIRM_CALLED", result.stderr)
self.assertIn("STOP_CALLED", result.stderr)
with self.subTest(answer="no"):
result = self.simulate(process, confirm=False)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("CONFIRM_CALLED", result.stderr)
self.assertNotIn("STOP_CALLED", result.stderr)
def test_unrelated_versioned_python_is_preserved(self):
result = self.simulate(r'''[pscustomobject]@{
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
CommandLine='python -m streamlit run "C:\other-project\app.py"'
}''')
self.assertNotEqual(result.returncode, 0)
self.assertIn("PID 12345", result.stderr)
self.assertNotIn("CONFIRM_CALLED", result.stderr)
self.assertNotIn("STOP_CALLED", result.stderr)
def test_exe_recognizes_sibling_development_server(self):
process = r'''[pscustomobject]@{
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' +
(($env:APP_RESTART_PATHS | ConvertFrom-Json)[1]) +
'" --server.port=8501 --server.address=localhost')
}'''
for answer, expected_code in [(True, 0), (False, 2)]:
with self.subTest(confirm=answer):
result = self.simulate(process, frozen=True, confirm=answer)
self.assertEqual(result.returncode, expected_code, result.stderr)
self.assertIn("CONFIRM_CALLED", result.stderr)
if answer:
self.assertIn("STOP_CALLED", result.stderr)
else:
self.assertNotIn("STOP_CALLED", result.stderr)
def test_exe_preserves_unrelated_development_server(self):
result = self.simulate(r'''[pscustomobject]@{
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
CommandLine='python -m streamlit run "C:\other-project\app.py"'
}''', frozen=True)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("CONFIRM_CALLED", result.stderr)
self.assertNotIn("STOP_CALLED", result.stderr)
def test_unrelated_server_is_preserved(self):
result = self.simulate(r'''[pscustomobject]@{
Name='python.exe'; CommandLine='streamlit run app.py';
ExecutablePath='C:\other-project\.venv\Scripts\python.exe'
}''')
self.assertNotEqual(result.returncode, 0)
self.assertIn("PID 12345", result.stderr)
self.assertNotIn("STOP_CALLED", result.stderr)
self.assertNotIn("CONFIRM_CALLED", result.stderr)
def test_stop_failure_aborts_start(self):
result = self.simulate(
"[pscustomobject]@{ Name='Tätigkeitsnachweis-Generator.exe'; CommandLine=''; ExecutablePath='' }",
stop_error=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn("simulated access denied", result.stderr)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
"""Prüft auf neuere Programmversionen im zentralen SMB-Verzeichnis."""
import re
from pathlib import Path
UPDATE_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
VERSION_PATTERN = re.compile(r"Tätigkeitsnachweis-Generator v(\d+)\.(\d+)\.(\d+)(?:\.\d+)?\.(zip|exe)$", re.IGNORECASE)
CHANGELOG_PATTERN = re.compile(r"^## \[(\d+)\.(\d+)(?:\.(\d+))?\]", re.MULTILINE)
def get_latest_version():
match = CHANGELOG_PATTERN.search(Path(__file__).with_name("CHANGELOG.md").read_text(encoding="utf-8"))
current = tuple(int(part or 0) for part in match.groups()) if match else (0, 0, 0)
try:
available = UPDATE_DIR.exists()
except OSError:
available = False
if not available:
return current, None
newer = []
try:
paths = UPDATE_DIR.iterdir()
for path in paths:
found = VERSION_PATTERN.fullmatch(path.name)
if found:
version = tuple(int(found.group(i)) for i in range(1, 4))
if version > current:
newer.append((version, path))
except OSError:
return current, None
if newer:
return current, max(newer)
return current, None
def main() -> int:
current, latest = get_latest_version()
print(f"[INFO] Lokale Version: v{'.'.join(map(str, current))}")
if latest:
version, path = latest
print(f"[HINWEIS] Neuere Version verfügbar: v{'.'.join(map(str, version))}")
print(f"[HINWEIS] Datei: {path}")
else:
print("[INFO] Keine neuere Version gefunden oder Update-Verzeichnis nicht erreichbar.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
1.3.4
+47
View File
@@ -0,0 +1,47 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
set "PYTHONUTF8=1"
cd /d "%~dp0"
cd ..
echo ============================================================
echo Tätigkeitsnachweis-Generator – Build-Pipeline
echo ============================================================
echo.
REM ── Virtuelle Umgebung aktivieren ────────────────────────────
if exist "0_application\.venv\Scripts\python.exe" (
set PYTHON=0_application\.venv\Scripts\python.exe
echo [OK] Virtuelle Umgebung gefunden
) else if exist ".venv\Scripts\python.exe" (
set PYTHON=.venv\Scripts\python.exe
echo [OK] Virtuelle Umgebung gefunden
) else (
echo [WARN] Keine virtuelle Umgebung gefunden, nutze System-Python
set PYTHON=python
)
echo.
REM ── Parameter durchreichen ──────────────────────────────────
set ARGS=%*
if "%ARGS%"=="" (
echo Starte interaktiven Build …
echo.
)
REM ── Build ausführen ─────────────────────────────────────────
%PYTHON% "1_build-pipeline\build.py" %ARGS%
if errorlevel 1 (
echo.
echo [FEHLER] Pipeline fehlgeschlagen. Details stehen oben.
pause
exit /b 1
)
echo.
echo Tests, Build und Deployment abgeschlossen.
pause
+457
View File
@@ -0,0 +1,457 @@
"""Build-Orchestrierung mit Semantic Versioning.
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
Benutzereingabe (major/minor/patch), aktualisiert das Changelog
führt zuerst alle Tests aus, baut via PyInstaller eine standalone .exe
nach 2_build/ und stellt sie anschließend als ZIP auf dem SMB-Laufwerk bereit.
Verwendung:
python build.py # interaktiver Modus
python build.py --bump minor # nicht-interaktiv
python build.py --bump patch --message "Hotfix: PDF-Schriftgröße korrigiert"
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from datetime import date
from pathlib import Path
from typing import Optional
from deploy import deploy, is_template_pdf, write_release_manifest
# ── Pfade ───────────────────────────────────────────────────────
BUILD_PIPELINE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BUILD_PIPELINE_DIR.parent
APP_DIR = PROJECT_ROOT / "0_application"
BUILD_OUTPUT_DIR = PROJECT_ROOT / "2_build"
CHANGELOG_PATH = APP_DIR / "CHANGELOG.md"
SPEC_PATH = BUILD_PIPELINE_DIR / "build.spec"
VERSION_FILE = BUILD_PIPELINE_DIR / ".last_version"
# ── Versions-Helfer ─────────────────────────────────────────────
def _read_changelog() -> str:
"""Liest das gesamte CHANGELOG.md."""
if not CHANGELOG_PATH.exists():
return ""
return CHANGELOG_PATH.read_text(encoding="utf-8")
def _write_changelog(content: str) -> None:
"""Schreibt CHANGELOG.md."""
CHANGELOG_PATH.write_text(content, encoding="utf-8")
def get_current_version() -> Optional[str]:
"""Ermittelt die aktuellste Version aus CHANGELOG.md.
Sucht nach dem ersten Vorkommen von '## [X.Y.Z]' oder '## [X.Y]'.
"""
content = _read_changelog()
if not content:
return None
match = re.search(r"##\s*\[(\d+\.\d+(?:\.\d+)?)\]", content)
return match.group(1) if match else None
def bump_version(current: str, bump_type: str) -> str:
"""Erhöht eine Semantic-Version.
Args:
current: Aktuelle Version (z.B. '1.1' oder '1.1.0')
bump_type: 'major', 'minor' oder 'patch'
Returns:
Neue Version als String.
"""
parts = current.split(".")
# Normalisiere auf 3 Teile (MAJOR.MINOR.PATCH)
while len(parts) < 3:
parts.append("0")
major, minor, patch = map(int, parts[:3])
if bump_type == "major":
major += 1
minor = 0
patch = 0
elif bump_type == "minor":
minor += 1
patch = 0
elif bump_type == "patch":
patch += 1
else:
raise ValueError(f"Unbekannter Bump-Typ: {bump_type}")
# Gib kurzes Format zurück wenn Patch=0
if patch == 0:
return f"{major}.{minor}"
return f"{major}.{minor}.{patch}"
def update_changelog(
new_version: str,
bump_type: str,
message: Optional[str] = None,
) -> None:
"""Fügt einen neuen Versionseintrag am Anfang des Changelogs ein.
Args:
new_version: Die neue Version (z.B. '1.2')
bump_type: Art des Bumps (für die Sektions-Vorlage)
message: Optionale Beschreibung der Änderungen.
"""
today = date.today().isoformat()
old_content = _read_changelog()
# Sektionen-Vorlage je nach Bump-Typ
sections = _build_sections(bump_type, message)
header = (
f"## [{new_version}] – {today}\n\n"
f"{sections}\n"
f"---\n\n"
)
# Nach dem ersten '---' einfügen (nach dem Dateikopf von Keep a Changelog)
# oder einfach am Anfang nach der ersten Zeile
lines = old_content.splitlines(keepends=True)
# Finde die erste Version-Header-Zeile (## [X.Y...)
insert_idx = None
for i, line in enumerate(lines):
if re.match(r"##\s*\[\d+\.\d+", line):
insert_idx = i
break
if insert_idx is not None:
new_content = "".join(lines[:insert_idx]) + header + "".join(lines[insert_idx:])
else:
# Fallback: nach dem Dateikopf (nach dem ersten '---')
for i, line in enumerate(lines):
if line.strip() == "---":
insert_idx = i + 1
break
if insert_idx is not None:
new_content = "".join(lines[:insert_idx]) + "\n" + header + "".join(lines[insert_idx:])
else:
new_content = old_content + "\n" + header
_write_changelog(new_content)
print(f" ✓ CHANGELOG.md aktualisiert → [{new_version}]")
def _wait_for_changelog_edit() -> None:
"""Gibt dem Benutzer Zeit, den neuen Changelog-Eintrag zu bearbeiten.
Der Build liest CHANGELOG.md erst nach dieser Pause; Änderungen in der
Datei fließen also in den Build ein.
"""
print(f"\n✏️ Bitte den neuen Eintrag jetzt bearbeiten und speichern:\n {CHANGELOG_PATH}")
try:
input(" Danach Enter drücken, um den Build fortzusetzen (Strg+C bricht ab) … ")
except (EOFError, KeyboardInterrupt):
print("\nAbgebrochen.")
raise KeyboardInterrupt
def _build_sections(bump_type: str, message: Optional[str]) -> str:
"""Baut die Sektionen für den Changelog-Eintrag."""
if message:
if bump_type == "major":
return f"### Hinzugefügt\n\n- {message}\n"
elif bump_type == "minor":
return f"### Hinzugefügt\n\n- {message}\n"
else:
return f"### Behoben\n\n- {message}\n"
# Standard-Vorlagen
if bump_type == "major":
return (
"### Hinzugefügt\n\n"
"- \n\n"
"### Geändert\n\n"
"- \n\n"
"### Entfernt\n\n"
"- \n"
)
elif bump_type == "minor":
return (
"### Hinzugefügt\n\n"
"- \n\n"
"### Geändert\n\n"
"- \n"
)
else:
return (
"### Behoben\n\n"
"- \n"
)
# ── Build ───────────────────────────────────────────────────────
def _run_tests() -> None:
"""Test with the same interpreter used to build the application."""
try:
import pytest # noqa: F401
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "pytest"])
print("\n[INFO] Führe alle Tests aus ...", flush=True)
result = subprocess.run(
[sys.executable, "-m", "pytest", str(APP_DIR / "tests"), "-v", "-p", "no:cacheprovider"],
cwd=str(PROJECT_ROOT),
)
if result.returncode != 0:
print("[FEHLER] Tests fehlgeschlagen. Kein Build und kein Deployment.")
raise SystemExit(1)
def _ensure_pyinstaller() -> None:
"""Stellt sicher, dass PyInstaller installiert ist."""
try:
import PyInstaller # noqa: F401
except ImportError:
print("⚙ PyInstaller wird installiert …")
subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"])
print(" ✓ PyInstaller installiert")
def _prepare_build_dir() -> None:
"""Bereitet das 2_build/-Verzeichnis vor."""
BUILD_OUTPUT_DIR.mkdir(exist_ok=True)
# Erstelle/aktualisiere Unterordner
for folder in ["input", "output", "template", "config"]:
target = BUILD_OUTPUT_DIR / folder
target.mkdir(exist_ok=True)
# Liefere die zum aktuellen Quellstand gehörende Konfiguration aus.
config_src = APP_DIR / "config"
config_dst = BUILD_OUTPUT_DIR / "config"
if config_src.exists():
for f in config_src.iterdir():
if f.is_file():
shutil.copy2(f, config_dst / f.name)
shutil.copy2(CHANGELOG_PATH, BUILD_OUTPUT_DIR / "CHANGELOG.md")
# Alle Template-PDFs ausliefern (ohne Dateien mit '_' oder '.' am Anfang).
# Veraltete PDFs aus früheren Builds werden entfernt.
tpl_src = APP_DIR / "template"
tpl_dst = BUILD_OUTPUT_DIR / "template"
wanted = {p.name for p in tpl_src.iterdir() if is_template_pdf(p)}
for old_pdf in tpl_dst.iterdir():
if is_template_pdf(old_pdf) and old_pdf.name not in wanted:
old_pdf.unlink()
for name in sorted(wanted):
shutil.copy2(tpl_src / name, tpl_dst / name)
# README für input-Ordner
input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt"
if not input_readme.exists():
input_readme.write_text(
"Excel-Dateien (.xlsx) in diesen Ordner legen.\n"
"Die App erkennt sie automatisch.\n",
encoding="utf-8",
)
# README für template-Ordner
tpl_readme = BUILD_OUTPUT_DIR / "template" / "_DATEIEN_HIER_ABLEGEN_.txt"
if not tpl_readme.exists():
tpl_readme.write_text(
"PDF-Templates (.pdf) mit _Field-Platzhaltern in diesen Ordner legen.\n"
"Die App erkennt sie automatisch.\n",
encoding="utf-8",
)
# README.md aus 0_application/ kopieren
readme_src = APP_DIR / "README.md"
if readme_src.exists():
shutil.copy2(readme_src, BUILD_OUTPUT_DIR / "README.md")
print(" ✓ README.md nach 2_build/ kopiert")
print(f" ✓ 2_build/ vorbereitet")
def _run_pyinstaller() -> Path:
"""Führt PyInstaller mit der Spec-Datei aus.
Returns:
Pfad zur erstellten .exe.
"""
print("\n🔨 PyInstaller baut .exe …")
print(" (Dies kann einige Minuten dauern)\n")
result = subprocess.run(
[
sys.executable, "-m", "PyInstaller",
str(SPEC_PATH),
"--distpath", str(BUILD_PIPELINE_DIR / "dist"),
"--workpath", str(BUILD_PIPELINE_DIR / "build_temp"),
"--noconfirm",
"--clean",
],
cwd=str(PROJECT_ROOT),
capture_output=False,
)
if result.returncode != 0:
print("\n❌ PyInstaller-Build fehlgeschlagen!")
sys.exit(1)
exe_path = BUILD_PIPELINE_DIR / "dist" / "Tätigkeitsnachweis-Generator.exe"
if not exe_path.exists():
print(f"\n❌ .exe nicht gefunden: {exe_path}")
sys.exit(1)
print(f" ✓ .exe erstellt: {exe_path.name}")
return exe_path
def _copy_to_build(exe_path: Path) -> Path:
"""Kopiert die .exe nach 2_build/."""
dest = BUILD_OUTPUT_DIR / exe_path.name
shutil.copy2(exe_path, dest)
print(f" ✓ .exe nach 2_build/ kopiert")
return dest
def _cleanup() -> None:
"""Räumt temporäre Build-Artefakte auf."""
for d in ["dist", "build_temp"]:
path = BUILD_PIPELINE_DIR / d
if path.exists():
shutil.rmtree(path, ignore_errors=True)
print(" ✓ Temporäre Dateien entfernt")
def _save_version(version: str) -> None:
"""Speichert die Version für spätere Referenz."""
VERSION_FILE.write_text(version)
def _print_summary(version: str, exe_path: Path) -> None:
"""Gibt eine Build-Zusammenfassung aus."""
print(f"\n{'='*60}")
print(f" ✅ Build {version} erfolgreich!")
print(f" 📂 {exe_path}")
print(f"{'='*60}\n")
print("Auslieferungs-Struktur (2_build/):")
print(" ├── Tätigkeitsnachweis-Generator.exe")
print(" ├── config/ ← Konfiguration (editierbar)")
print(" ├── input/ ← Excel-Dateien hier ablegen")
print(" ├── template/ ← PDF-Templates hier ablegen")
print(" └── output/ ← Generierte PDFs erscheinen hier")
# ── CLI ─────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Build mit Semantic Versioning",
)
version_options = parser.add_mutually_exclusive_group()
version_options.add_argument(
"--bump",
choices=["major", "minor", "patch"],
help="Version bump-Typ (ohne Angabe: interaktiv)",
)
version_options.add_argument("--no-bump", action="store_true",
help="Aktuelle Version erneut bauen; Changelog unverändert übernehmen")
parser.add_argument("--no-deploy", action="store_true",
help="Nur lokal bauen, ohne anschließendes Deployment")
parser.add_argument(
"--message", "-m",
help="Changelog-Beschreibung für diese Version",
)
parser.add_argument(
"--no-pause", action="store_true",
help="Nicht auf die Bearbeitung des Changelogs warten (z.B. für CI)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, was passieren würde (kein Build)",
)
args = parser.parse_args()
# ── Version ermitteln ────────────────────────────────────
current = get_current_version()
if not current:
if args.no_bump:
parser.error("Für --no-bump muss eine Version im CHANGELOG.md vorhanden sein.")
print("❌ Keine Version in CHANGELOG.md gefunden. Beginne mit 0.1.")
current = "0.1"
print(f"\n📋 Aktuelle Version: {current}")
# Bump-Typ bestimmen
bump_type = args.bump
if not bump_type and not args.no_bump:
print("\nWelche Art von Änderung?")
print(" [1] major – inkompatible API-Änderungen")
print(" [2] minor – neue Funktionalität (abwärtskompatibel)")
print(" [3] patch – Bugfixes")
try:
choice = input(" → ").strip()
bump_type = {"1": "major", "2": "minor", "3": "patch"}.get(choice, "minor")
except (EOFError, KeyboardInterrupt):
print("\nAbgebrochen.")
sys.exit(0)
new_version = current if args.no_bump else bump_version(current, bump_type)
print(f"🚀 Neue Version: {new_version}")
if args.dry_run:
print("\n[Trockenlauf – kein Build]")
print(" Würde zuerst alle Tests ausführen")
if not args.no_bump:
print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]")
print(f" Würde PyInstaller-Build starten")
if not args.no_deploy:
print(" Würde anschließend ein versioniertes ZIP auf dem SMB-Laufwerk bereitstellen")
return
_run_tests()
previous_changelog = _read_changelog()
try:
_ensure_pyinstaller()
(BUILD_OUTPUT_DIR / "release.json").unlink(missing_ok=True)
if not args.no_bump:
print("\n📝 Changelog …")
update_changelog(new_version, bump_type, args.message)
if not args.no_pause and sys.stdin.isatty():
_wait_for_changelog_edit()
_prepare_build_dir()
exe_path = _run_pyinstaller()
final_exe = _copy_to_build(exe_path)
write_release_manifest(BUILD_OUTPUT_DIR, new_version)
except (Exception, SystemExit, KeyboardInterrupt):
_write_changelog(previous_changelog)
raise
finally:
_cleanup()
_save_version(new_version)
_print_summary(new_version, final_exe)
if args.no_deploy:
print("[INFO] Lokaler Build abgeschlossen; Deployment nicht angefordert.")
return
try:
deploy()
except (OSError, ValueError, KeyError) as exc:
print(f"[FEHLER] {exc}")
print("[INFO] Der Build ist fertig. Deployment später mit 1_build-pipeline\\deploy.bat wiederholen.")
raise SystemExit(1)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
print(f"[FEHLER] Pipeline abgebrochen: {exc}")
raise SystemExit(1)
+161
View File
@@ -0,0 +1,161 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller Spec-Datei für den Tätigkeitsnachweis-Generator.
Baut eine standalone .exe aus run.py, die Streamlit, openpyxl und
PyMuPDF (fitz) enthält.
Verwendung:
pyinstaller build.spec
"""
import os
import sys
from pathlib import Path
# Pfade relativ zum Speicherort dieser Spec-Datei
# PyInstaller stellt SPECPATH bereit (__file__ existiert in exec()-Kontext nicht)
_SPEC_DIR = Path(SPECPATH).resolve()
_PROJECT_ROOT = _SPEC_DIR.parent
_APP_DIR = _PROJECT_ROOT / "0_application"
# ── Streamlit & PyMuPDF Hidden Imports ──────────────────────────
# Streamlit hat viele dynamische Imports; collect_all holt alles.
# PyMuPDF (fitz) braucht die native DLL.
hiddenimports = [
# Streamlit-Komponenten
"streamlit",
"streamlit.web.bootstrap",
"streamlit.runtime",
"streamlit.runtime.scriptrunner",
"streamlit.runtime.state",
"streamlit.runtime.caching",
"streamlit.watcher",
"streamlit.watcher.local_sources_watcher",
"streamlit.watcher.poll_path_watcher",
"streamlit.watcher.event_based_path_watcher",
"streamlit.elements",
"streamlit.elements.arrow",
"streamlit.elements.pyplot",
"streamlit.elements.altair",
"streamlit.elements.plotly",
"streamlit.proto",
# openpyxl
"openpyxl",
"openpyxl.cell",
"openpyxl.styles",
# PyMuPDF
"fitz",
# Standard-Libs die manchmal übersehen werden
"tornado",
"watchdog",
"pandas",
"numpy",
"PIL",
"altair",
"pyarrow",
"git",
"blinker",
"rich",
"pydeck",
]
# ── Binaries (PyMuPDF DLL) ──────────────────────────────────────
binaries = []
# Finde die PyMuPDF-DLL
try:
import fitz
fitz_dir = Path(fitz.__file__).parent
for f in fitz_dir.iterdir():
if f.suffix.lower() in (".dll", ".pyd", ".so", ".dylib"):
binaries.append((str(f), "fitz"))
except ImportError:
pass
# ── Data Files ──────────────────────────────────────────────────
datas = [
(str(_APP_DIR / "app.py"), "."),
(str(_APP_DIR / "version_check.py"), "."),
(str(_APP_DIR / "CHANGELOG.md"), "."),
(str(_APP_DIR / "src"), "src"),
(str(_APP_DIR / "config"), "config"),
]
# ── Alle Streamlit-Abhängigkeiten & Metadaten VOR Analysis() sammeln ─
from PyInstaller.utils.hooks import collect_all, copy_metadata
streamlit_datas, streamlit_binaries, streamlit_hidden = collect_all("streamlit")
datas.extend(streamlit_datas)
binaries.extend(streamlit_binaries)
hiddenimports.extend(streamlit_hidden)
# Package-Metadaten: streamlit & Co. nutzen importlib.metadata.version(),
# das die METADATA-Dateien aus dem dist-info-Ordner benötigt.
for pkg in ["streamlit", "pyarrow", "numpy", "pandas", "altair", "openpyxl"]:
try:
datas += copy_metadata(pkg)
except Exception:
pass
# Deduplizieren (Datas und Hiddenimports)
hiddenimports = list(set(hiddenimports))
seen = set()
_unique_datas = []
for src, dst in datas:
key = (src, dst)
if key not in seen:
seen.add(key)
_unique_datas.append((src, dst))
datas = _unique_datas
# ── Analyse ─────────────────────────────────────────────────────
a = Analysis(
[str(_SPEC_DIR / "run.py")],
pathex=[str(_APP_DIR)],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
"tkinter",
"matplotlib",
"scipy",
"IPython",
"jupyter",
"notebook",
"sqlalchemy",
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
# ── PyZ ─────────────────────────────────────────────────────────
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
# ── EXE ─────────────────────────────────────────────────────────
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="Tätigkeitsnachweis-Generator",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # True = Konsolenfenster sichtbar (zeigt Streamlit-Logs)
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+18
View File
@@ -0,0 +1,18 @@
@echo off
chcp 65001 >nul
setlocal
set "PYTHONUTF8=1"
cd /d "%~dp0.."
if exist "0_application\.venv\Scripts\python.exe" (
set "DEPLOY_PYTHON=0_application\.venv\Scripts\python.exe"
) else if exist ".venv\Scripts\python.exe" (
set "DEPLOY_PYTHON=.venv\Scripts\python.exe"
) else (
set "DEPLOY_PYTHON=python"
)
"%DEPLOY_PYTHON%" "1_build-pipeline\deploy.py"
set "DEPLOY_EXIT_CODE=%ERRORLEVEL%"
if /I not "%~1"=="--no-pause" pause
exit /b %DEPLOY_EXIT_CODE%
+160
View File
@@ -0,0 +1,160 @@
"""Package and publish the last successful build to the SMB update directory."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
import zipfile
PROJECT_ROOT = Path(__file__).resolve().parent.parent
BUILD_DIR = PROJECT_ROOT / "2_build"
DEPLOY_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
MANIFEST_NAME = "release.json"
PACKAGE_FILES = [
EXE_NAME, "README.md", "CHANGELOG.md",
"config/settings.ini", "config/input-fields.json", "config/output-fields.json",
]
def is_template_pdf(path: Path) -> bool:
"""Nur *.pdf, die nicht mit '_' oder '.' beginnen, gelten als Template."""
return path.is_file() and path.suffix.lower() == ".pdf" and not path.name.startswith(("_", "."))
def template_files(base_dir: Path) -> list[str]:
"""Relative Pfade aller Template-PDFs in base_dir/template (sortiert)."""
folder = base_dir / "template"
if not folder.is_dir():
return []
return [f"template/{p.name}" for p in sorted(folder.iterdir()) if is_template_pdf(p)]
def package_files(build_dir: Path) -> list[str]:
"""Alle Dateien des Release-Pakets: feste Dateien plus alle Template-PDFs."""
return PACKAGE_FILES + template_files(build_dir)
def normalize_version(version: str) -> str:
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", version):
raise ValueError(f"Ungültige Versionsnummer: {version}")
return ".".join(str(int(part)) for part in (version.split(".") + ["0"])[:3])
def file_hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_release_manifest(build_dir: Path, version: str) -> None:
"""Record the version and all payload hashes after a successful build."""
manifest = {
"version": normalize_version(version),
"files": {name: file_hash(build_dir / name) for name in package_files(build_dir)},
}
(build_dir / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def validate_release(build_dir: Path) -> str:
if not (build_dir / MANIFEST_NAME).is_file():
raise ValueError("Kein bestätigter Build vorhanden. Bitte zuerst build.bat ausführen.")
manifest = json.loads((build_dir / MANIFEST_NAME).read_text(encoding="utf-8"))
version = normalize_version(manifest["version"])
for name in package_files(build_dir):
path = build_dir / name
if not path.is_file() or file_hash(path) != manifest.get("files", {}).get(name):
raise ValueError(f"Build-Datei fehlt oder wurde nach dem Build verändert: {name}. Bitte neu bauen.")
return version
def check_deploy_target(target_dir: Path) -> None:
"""Never create the SMB destination: a missing mount must be an error."""
try:
if not target_dir.is_dir():
raise OSError("Verzeichnis ist nicht verfügbar")
# Actually access the directory; exists() alone does not check read access.
with os.scandir(target_dir) as entries:
next(entries, None)
except OSError as exc:
raise OSError(
f"Deployment-Ziel nicht erreichbar: {target_dir}\n"
"Bitte VPN-Verbindung und Netzlaufwerk J: prüfen und deploy.bat erneut starten."
) from exc
def deploy(build_dir: Path = BUILD_DIR, target_dir: Path = DEPLOY_DIR) -> Path:
version = validate_release(build_dir)
check_deploy_target(target_dir)
archive_name = f"Tätigkeitsnachweis-Generator v{version}.zip"
archive = build_dir / archive_name
descriptor, temporary = tempfile.mkstemp(prefix="release-", suffix=".zip", dir=build_dir)
os.close(descriptor)
temporary = Path(temporary)
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as package:
for name in package_files(build_dir):
package.write(build_dir / name, name)
for directory in ["input/", "output/"]:
# Fixed timestamps keep repeated deployments byte-identical.
package.writestr(zipfile.ZipInfo(directory, date_time=(2020, 1, 1, 0, 0, 0)), b"")
with zipfile.ZipFile(temporary) as package:
if package.testzip() is not None:
raise ValueError("Das erzeugte ZIP-Paket ist beschädigt.")
temporary.replace(archive)
finally:
temporary.unlink(missing_ok=True)
destination = target_dir / archive_name
if destination.exists():
if file_hash(destination) == file_hash(archive):
print(f"[OK] Diese Version ist bereits unverändert bereitgestellt: {destination}")
return destination
raise FileExistsError(f"{destination} existiert bereits mit anderem Inhalt. Bitte eine neue Version bauen.")
# Write access is checked by creating the staging file on the actual share.
remote_temporary = None
try:
descriptor, name = tempfile.mkstemp(prefix=".upload-", suffix=".tmp", dir=target_dir)
os.close(descriptor)
remote_temporary = Path(name)
shutil.copyfile(archive, remote_temporary)
if file_hash(remote_temporary) != file_hash(archive):
raise OSError("Die Prüfsumme nach der SMB-Übertragung stimmt nicht überein.")
# On Windows rename refuses to overwrite another published release.
remote_temporary.rename(destination)
except OSError as exc:
raise OSError(
"Deployment fehlgeschlagen. VPN-Verbindung und Schreibrechte prüfen. "
"Der erfolgreiche lokale Build bleibt erhalten; erneut mit deploy.bat starten."
) from exc
finally:
if remote_temporary is not None:
try:
remote_temporary.unlink(missing_ok=True)
except OSError:
pass # The VPN may have disconnected during transfer.
print(f"[OK] Deployment abgeschlossen: {destination}")
return destination
def main() -> int:
parser = argparse.ArgumentParser(description="Letzten erfolgreichen Build auf dem SMB-Laufwerk bereitstellen")
parser.parse_args()
try:
deploy()
except (OSError, ValueError, KeyError) as exc:
print(f"[FEHLER] {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+109
View File
@@ -0,0 +1,109 @@
"""Einstiegspunkt für die gebundelte Tätigkeitsnachweis-Generator .exe.
Wird von PyInstaller als Entry-Point verwendet. Im gebundelten Modus
(sys.frozen = True) liegen die Python-Dateien (app.py, src/) in sys._MEIPASS,
während die Daten-Ordner (config/, input/, template/, output/) neben der .exe
erwartet werden.
Dieses Skript:
1. Setzt APP_BASE_DIR auf das Verzeichnis der .exe
2. Fügt sys._MEIPASS zum Python-Pfad hinzu (für src/-Imports)
3. Startet den Streamlit-Server und öffnet den Browser
"""
import os
import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
if not getattr(sys, "frozen", False):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "0_application"))
from server_lifecycle import stop_existing_server
def _get_base_dir() -> Path:
"""Verzeichnis der .exe (bzw. des Skripts im Entwicklungsmodus)."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).resolve().parent
else:
# Entwicklungsmodus: run.py liegt in 1_build-pipeline/,
# die App in 0_application/
return Path(__file__).resolve().parent.parent / "0_application"
def _get_app_path() -> str:
"""Pfad zur app.py (gebundelt oder im Entwicklungsmodus)."""
if getattr(sys, 'frozen', False):
return os.path.join(sys._MEIPASS, "app.py")
else:
return str(Path(__file__).resolve().parent.parent / "0_application" / "app.py")
def _ensure_dirs(base_dir: Path) -> None:
"""Stellt sicher, dass input/, output/, template/, config/ existieren."""
for folder in ["input", "output", "template", "config"]:
(base_dir / folder).mkdir(exist_ok=True)
def main() -> None:
# Only open the browser/start Streamlit once the previous server has stopped.
try:
if not stop_existing_server():
print("[INFO] Die bisherige Version bleibt geöffnet. Keine neue Instanz gestartet.")
return
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
print(f"[FEHLER] Server-Neustart fehlgeschlagen: {exc}", file=sys.stderr)
raise SystemExit(1)
base_dir = _get_base_dir()
# Im gebundelten Modus: sys._MEIPASS enthält src/ und app.py
if getattr(sys, 'frozen', False):
if sys._MEIPASS not in sys.path:
sys.path.insert(0, sys._MEIPASS)
# APP_BASE_DIR teilt app.py mit, wo die Daten-Ordner liegen
os.environ["APP_BASE_DIR"] = str(base_dir)
_ensure_dirs(base_dir)
# ── Streamlit-Konfiguration ──────────────────────────
os.environ["STREAMLIT_SERVER_ADDRESS"] = "localhost"
os.environ["STREAMLIT_SERVER_PORT"] = "8501"
os.environ["STREAMLIT_SERVER_HEADLESS"] = "true"
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
# Browser manuell öffnen
def _open_browser() -> None:
time.sleep(2)
webbrowser.open("http://localhost:8501")
threading.Thread(target=_open_browser, daemon=True).start()
# ── Streamlit via CLI-Codepfad starten ───────────────
# Dieser Weg ist identisch zu 'streamlit run app.py' und
# funktioniert im PyInstaller-Bundle zuverlässiger als
# bootstrap.run() mit Streamlit >= 1.58.
sys.argv = [
"streamlit", "run", _get_app_path(),
"--server.port=8501",
"--server.address=localhost",
"--server.headless=true",
"--browser.gatherUsageStats=false",
"--server.enableCORS=false",
"--server.enableXsrfProtection=false",
"--global.developmentMode=false",
]
try:
from streamlit.cli import main as cli_main
except ImportError:
from streamlit.web.cli import main as cli_main
cli_main()
if __name__ == "__main__":
main()
-322
View File
@@ -1,322 +0,0 @@
"""Tätigkeitsnachweis-Generator – Streamlit App."""
import os
import subprocess
import sys
from pathlib import Path
import streamlit as st
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import (
load_settings,
load_input_schema,
load_output_mapping,
get_available_input_files,
get_available_templates,
)
from src.models import AppConfig
from src.parser import parse_input_file, group_by_employee, get_unique_antragsnummern, get_unique_employees
from src.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports
# --- Seite konfigurieren ---
st.set_page_config(
page_title="Tätigkeitsnachweis-Generator",
page_icon="📋",
layout="wide",
)
st.title("📋 Tätigkeitsnachweis-Generator")
st.markdown("Erstellt Tätigkeitsnachweise aus Input-Daten und einem Excel-Template.")
# --- Konfiguration laden ---
CONFIG_DIR = PROJECT_ROOT / "config"
@st.cache_data
def load_app_config():
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR)
return config
try:
app_config = load_app_config()
except Exception as e:
st.error(f"Fehler beim Laden der Konfiguration: {e}")
st.stop()
# --- Session State initialisieren ---
if "parsed_records" not in st.session_state:
st.session_state.parsed_records = []
if "input_validated" not in st.session_state:
st.session_state.input_validated = False
if "template_validated" not in st.session_state:
st.session_state.template_validated = False
# Caches für Validierung/Parsing – nur bei Dateiwechsel neu ausführen
if "last_input_file" not in st.session_state:
st.session_state.last_input_file = None
if "last_template_file" not in st.session_state:
st.session_state.last_template_file = None
if "cached_val_result" not in st.session_state:
st.session_state.cached_val_result = None
if "cached_tpl_result" not in st.session_state:
st.session_state.cached_tpl_result = None
# --- Layout: zwei Spalten ---
col1, col2 = st.columns(2)
with col1:
st.subheader("1. Input-Datei")
input_files = get_available_input_files(app_config)
if not input_files:
st.warning("Keine .xlsx-Dateien im input/-Ordner gefunden.")
selected_input = None
else:
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
if len(input_files) == 1:
selected_input = st.selectbox(
"Input-Datei wählen:",
options=input_files,
)
else:
selected_input = st.selectbox(
"Input-Datei wählen:",
options=[""] + input_files,
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
)
if selected_input:
input_path = Path(app_config.input_dir) / selected_input
# Nur validieren/parsen wenn sich die Datei geändert hat
if selected_input != st.session_state.last_input_file:
with st.spinner("Validiere Input-Datei…"):
val_result = validate_input_file(input_path, app_config.input_schema, app_config.month_sheet_pattern)
if val_result.is_valid:
records = parse_input_file(
input_path,
app_config.input_schema,
app_config.month_sheet_pattern,
app_config.skip_empty_name,
app_config.skip_empty_antragsnummer,
)
st.session_state.parsed_records = records
st.session_state.input_validated = True
else:
st.session_state.parsed_records = []
st.session_state.input_validated = False
st.session_state.cached_val_result = val_result
st.session_state.last_input_file = selected_input
else:
val_result = st.session_state.cached_val_result
if not val_result.is_valid:
st.markdown(
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
'background:#fdedec; margin-bottom:12px">'
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
'❌ Validierung fehlgeschlagen</span></div>',
unsafe_allow_html=True,
)
for err in val_result.errors:
st.error(f"🔴 {err}")
st.session_state.input_validated = False
else:
if val_result.warnings:
st.markdown(
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
'background:#fef9e7; margin-bottom:8px">'
'<span style="color:#7d6608; font-weight:bold">'
'⚠️ Hinweise</span></div>',
unsafe_allow_html=True,
)
for warn in val_result.warnings:
st.warning(warn)
# Zeige Zusammenfassung aus gecachten Records
records = st.session_state.parsed_records
employees = get_unique_employees(records)
antragsnummern = get_unique_antragsnummern(records)
st.success(f"✅ {len(records)} Einträge in {val_result.info.get('sheets_with_data', 0)} Monats-Sheets")
pills = " ".join(
f'<span style="display:inline-block; background:#e8f0fe; color:#1a73e8; '
f'border-radius:16px; padding:4px 12px; margin:3px; font-size:0.9em; '
f'white-space:nowrap">{an}</span>'
for an in antragsnummern
)
st.markdown(f"**Antragsnummern:**<br>{pills}", unsafe_allow_html=True)
with col2:
st.subheader("2. Template")
template_files = get_available_templates(app_config)
if not template_files:
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
selected_template = None
else:
# Lock-Files herausfiltern
real_templates = [t for t in template_files if not t.startswith("~")]
if not real_templates:
real_templates = template_files
# Bei nur einem Template: automatisch selektieren (keine Leerauswahl)
if len(real_templates) == 1:
selected_template = st.selectbox(
"Template wählen:",
options=real_templates,
)
else:
selected_template = st.selectbox(
"Template wählen:",
options=[""] + real_templates,
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
)
if selected_template:
# Nur validieren wenn sich das Template geändert hat
if selected_template != st.session_state.last_template_file:
template_path = Path(app_config.template_dir) / selected_template
with st.spinner("Validiere Template…"):
tpl_result = validate_template(template_path, app_config.output_mapping)
st.session_state.cached_tpl_result = tpl_result
st.session_state.last_template_file = selected_template
st.session_state.template_validated = tpl_result.is_valid
else:
tpl_result = st.session_state.cached_tpl_result
if not tpl_result.is_valid:
st.markdown(
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
'background:#fdedec; margin-bottom:12px">'
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
'❌ Validierung fehlgeschlagen</span></div>',
unsafe_allow_html=True,
)
for err in tpl_result.errors:
st.error(f"🔴 {err}")
st.session_state.template_validated = False
else:
st.session_state.template_validated = True
found = tpl_result.info.get("found_fields", 0)
total = tpl_result.info.get("total_fields", 0)
st.success(f"✅ {found}/{total} Platzhalter-Felder gefunden")
if tpl_result.warnings:
st.markdown(
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
'background:#fef9e7; margin-bottom:8px">'
'<span style="color:#7d6608; font-weight:bold">'
'⚠️ Hinweise</span></div>',
unsafe_allow_html=True,
)
for warn in tpl_result.warnings:
st.warning(warn)
# --- Mitarbeiter-Auswahl ---
st.subheader("3. Mitarbeiter auswählen")
if st.session_state.input_validated and st.session_state.parsed_records:
employees = get_unique_employees(st.session_state.parsed_records)
st.caption(f"{len(employees)} Mitarbeiter gefunden – wähle aus, für wen Tätigkeitsnachweise erstellt werden sollen:")
# "Alle" / "Keine" Buttons
btn_col1, btn_col2 = st.columns(2)
if btn_col1.button("☑ Alle auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = True
if btn_col2.button("☐ Keine auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = False
# Mitarbeiter-Liste mit Rahmen
with st.container(border=True):
selected_employees = []
for emp in employees:
key = f"sel_{emp}"
if key not in st.session_state:
st.session_state[key] = True
if st.checkbox(emp, value=st.session_state[key], key=key):
selected_employees.append(emp)
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
else:
selected_employees = []
if not st.session_state.input_validated:
st.info("Bitte zuerst eine gültige Input-Datei auswählen.")
# --- Hilfsfunktion zum Öffnen von Dateien/Ordnern ---
def open_path(path_str: str):
"""Öffnet eine Datei mit dem Standardprogramm oder einen Ordner im Dateimanager.
Funktioniert plattformübergreifend: macOS, Windows, Linux."""
if sys.platform == "win32":
os.startfile(path_str)
elif sys.platform == "darwin":
subprocess.run(["open", path_str])
else:
subprocess.run(["xdg-open", path_str])
# --- Generate Button ---
st.divider()
gen_col1, gen_col2, gen_col3 = st.columns([1, 1, 2])
with gen_col1:
generate_clicked = st.button(
"🚀 Tätigkeitsnachweise generieren",
type="primary",
disabled=not (st.session_state.input_validated and st.session_state.template_validated and selected_employees),
)
if generate_clicked:
if not selected_employees:
st.error("Keine Mitarbeiter ausgewählt.")
else:
# Filtere Records auf selektierte Mitarbeiter
all_groups = group_by_employee(st.session_state.parsed_records)
selected_groups = {name: all_groups[name] for name in selected_employees if name in all_groups}
template_path = Path(app_config.template_dir) / selected_template
output_dir = Path(app_config.output_dir)
with st.spinner(f"Generiere {len(selected_groups)} Tätigkeitsnachweise…"):
try:
generated = generate_activity_reports(
employee_records=selected_groups,
template_path=template_path,
mapping=app_config.output_mapping,
output_dir=output_dir,
filename_pattern=app_config.filename_pattern,
)
st.session_state.generated_files = generated
except Exception as e:
st.error(f"Fehler bei der Generierung: {e}")
# --- Generierte Dateien anzeigen (aus Session State, übersteht Reruns) ---
if "generated_files" in st.session_state and st.session_state.generated_files:
generated = st.session_state.generated_files
st.success(f"✅ {len(generated)} Tätigkeitsnachweise erstellt:")
for i, fpath in enumerate(generated):
col_file, col_open, col_folder = st.columns([0.78, 0.11, 0.11])
with col_file:
st.markdown(f"📄 `{fpath.name}`")
with col_open:
st.button(
"🔗 Öffnen",
key=f"open_file_{i}",
on_click=open_path,
args=(str(fpath),),
help="Datei mit Standardprogramm öffnen",
use_container_width=True,
)
with col_folder:
st.button(
"📂",
key=f"open_folder_{i}",
on_click=open_path,
args=(str(fpath.parent),),
help="Ordner im Finder öffnen",
use_container_width=True,
)
Binary file not shown.
-220
View File
@@ -1,220 +0,0 @@
"""End-to-End-Test: Reale Input-Datei + reales PDF-Template → PDF-Output für erste Person."""
import sys
from pathlib import Path
import fitz # PyMuPDF
import pytest
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import load_settings, load_input_schema, load_output_mapping
from src.parser import parse_input_file, group_by_employee, get_unique_employees
from src.writer import generate_activity_reports
# ──────────────────────────────────────────────
# Pfade zu den realen Fixture-Dateien
# ──────────────────────────────────────────────
CONFIG_DIR = PROJECT_ROOT / "config"
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
def _load_app_config():
"""Lädt die echte App-Konfiguration (Schema, Mapping, Settings)."""
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR)
return config
# ──────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────
@pytest.fixture(scope="module")
def app_config():
"""Reale App-Konfiguration aus config/."""
return _load_app_config()
@pytest.fixture(scope="module")
def parsed_records(app_config):
"""Geparste Records aus der realen Input-Datei."""
return parse_input_file(
filepath=INPUT_FILE,
schema=app_config.input_schema,
month_sheet_pattern=app_config.month_sheet_pattern,
skip_empty_name=app_config.skip_empty_name,
skip_empty_antragsnummer=app_config.skip_empty_antragsnummer,
)
@pytest.fixture
def output_dir(tmp_path):
"""Temporäres Output-Verzeichnis."""
out = tmp_path / "output"
out.mkdir()
return out
# ──────────────────────────────────────────────
# Hilfsfunktion: Text aus PDF extrahieren
# ──────────────────────────────────────────────
def _get_pdf_text(pdf_path: Path) -> str:
"""Extrahiert den gesamten Text aus einem PDF."""
doc = fitz.open(pdf_path)
text = ""
for page in doc:
text += page.get_text()
doc.close()
return text
def _get_page_text(pdf_path: Path, page_num: int = 0) -> str:
"""Extrahiert Text von einer bestimmten PDF-Seite."""
doc = fitz.open(pdf_path)
text = doc[page_num].get_text() if page_num < doc.page_count else ""
doc.close()
return text
# ──────────────────────────────────────────────
# End-to-End Tests
# ──────────────────────────────────────────────
class TestEndToEndFirstPerson:
"""End-to-End: Reale Input-Datei + reales PDF-Template → nur erste Person."""
def test_input_file_exists(self):
"""Stellt sicher, dass die reale Input-Datei vorhanden ist."""
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
def test_template_file_exists(self):
"""Stellt sicher, dass das reale PDF-Template vorhanden ist."""
assert TEMPLATE_FILE.exists(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
def test_template_has_field_markers(self, app_config):
"""Stellt sicher, dass alle _Field-Platzhalter im PDF-Template vorhanden sind."""
doc = fitz.open(TEMPLATE_FILE)
missing = []
for m in app_config.output_mapping:
if m.pdf_field is None:
continue
# Suche auf allen Seiten
found_any = False
for page_num in range(doc.page_count):
if doc[page_num].search_for(m.pdf_field):
found_any = True
break
if not found_any:
missing.append(m.pdf_field)
doc.close()
assert len(missing) == 0, (
f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
)
def test_parse_real_input(self, parsed_records):
"""Validiert das Parsen der realen Input-Datei."""
# Sollte 168 Einträge über 12 Monate für 19 Personen ergeben
assert len(parsed_records) == 168, (
f"Erwartet 168 Records, aber {len(parsed_records)} gefunden"
)
employees = get_unique_employees(parsed_records)
assert len(employees) == 19, (
f"Erwartet 19 Mitarbeiter, aber {len(employees)} gefunden"
)
# Erste Person alphabetisch: Draßdo, Cornelia
assert employees[0] == "Draßdo, Cornelia", (
f"Erwartet 'Draßdo, Cornelia' als erste Person, aber '{employees[0]}'"
)
def test_full_pipeline_first_person(
self, app_config, parsed_records, output_dir
):
"""Kompletter Workflow für die erste gefundene Person (Draßdo, Cornelia)."""
# 1. Nach Mitarbeiter gruppieren
grouped = group_by_employee(parsed_records)
assert len(grouped) == 19
# 2. Erste Person alphabetisch
first_person = sorted(grouped.keys())[0]
assert first_person == "Draßdo, Cornelia"
first_records = grouped[first_person]
# Draßdo hat 12 Einträge (einen pro Monat)
assert len(first_records) == 12, (
f"Erwartet 12 Records für {first_person}, aber {len(first_records)}"
)
# 3. Reports NUR für die erste Person generieren
single_person_group = {first_person: first_records}
generated = generate_activity_reports(
employee_records=single_person_group,
template_path=TEMPLATE_FILE,
mapping=app_config.output_mapping,
output_dir=output_dir,
filename_pattern=app_config.filename_pattern,
)
# 4. Ergebnisse prüfen
assert len(generated) == 1, (
f"Erwartet 1 generierte Datei, aber {len(generated)}"
)
output_file = generated[0]
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
assert output_file.suffix == ".pdf"
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf"
assert output_file.name == erwarteter_name, (
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
)
# 5. PDF öffnen und Inhalt validieren
doc = fitz.open(output_file)
assert doc.page_count == 24, (
f"Erwartet 24 Seiten (12 Monate × 2 Seiten), aber {doc.page_count}"
)
# 6. Prüfe, dass KEINE _Field-Platzhalter mehr im Output sind
full_text = ""
for page in doc:
full_text += page.get_text()
assert "_Field" not in full_text, (
"Es sind noch _Field-Platzhalter im Output-PDF vorhanden!"
)
# 7. Prüfe Seite 0 (Januar, Seite 1 – Stammdaten)
jan0_text = doc[0].get_text()
assert "100740439" in jan0_text, "Antragsnummer fehlt auf Januar-Seite 1"
assert "Draßdo, Cornelia" in jan0_text, "Name fehlt auf Januar-Seite 1"
assert "50" in jan0_text, "Stellenanteil fehlt auf Januar-Seite 1"
assert "Meisterabschluß Küche" in jan0_text, "Qualifikation fehlt auf Januar-Seite 1"
assert "01.2025" in jan0_text, "Monat fehlt auf Januar-Seite 1"
assert "Anleiterin / Ausbilderin" in jan0_text, "Funktion fehlt auf Januar-Seite 1"
assert "Tätigkeiten im Praxisbereich Garten" in jan0_text, "Tätigkeit fehlt auf Januar-Seite 1"
# 8. Prüfe Seite 1 (Januar, Seite 2 – Unterschriftsblock mit Ort/Datum)
jan1_text = doc[1].get_text()
assert "100740439" not in jan1_text, "Antragsnummer sollte nicht auf Seite 2 sein"
assert "Dresden" in jan1_text, "Ort 'Dresden' fehlt auf Januar-Seite 2"
assert "31.01.2025" in jan1_text, "Datum '31.01.2025' fehlt auf Januar-Seite 2"
# Tätigkeit wiederholt auf Seite 2
assert "Tätigkeiten im Praxisbereich Garten" in jan1_text, "Tätigkeit fehlt auf Januar-Seite 2"
# 9. Prüfe Juli (Seite 12+13) – Stellenanteil wechselt auf 40%
jul0_text = doc[12].get_text()
assert "40" in jul0_text, "Stellenanteil 40% fehlt auf Juli-Seite 1"
assert "07.2025" in jul0_text, "Monat 07.2025 fehlt auf Juli-Seite 1"
jul1_text = doc[13].get_text()
assert "31.07.2025" in jul1_text, "Datum 31.07.2025 fehlt auf Juli-Seite 2"
doc.close()