19 Commits
Author SHA1 Message Date
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
39 changed files with 1268 additions and 125 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 |
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
| `CHANGELOG.md` | Changelog – wird bei jedem Version-Build erweitert |
## 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
├── start.bat ← Windows batch zum Start der App
├── README.md ← README für Programmbenutzer
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
└── 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
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
+62 -7
View File
@@ -1,8 +1,63 @@
input/
output/
.venv/
__pycache__/
### macOS
# Finder metadata
.DS_Store
.~lock.*#
*.xlsx#
~*.xlsx
# Thumbnails
._*
# 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
+9
View File
@@ -0,0 +1,9 @@
input/
output/
.venv/
__pycache__/
.DS_Store
.~lock.*#
*.xlsx#
~*.xlsx
/versions
+140
View File
@@ -0,0 +1,140 @@
# 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] – 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
File renamed without changes.
+57
View File
@@ -0,0 +1,57 @@
# Tätigkeitsnachweis-Generator
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\"
Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
## Bedienung
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
- 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 ist keiner ausgewählt
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
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
## 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`
+184 -10
View File
@@ -8,7 +8,14 @@ from pathlib import Path
import streamlit as st
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent
# 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 (
@@ -22,6 +29,27 @@ 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 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(
@@ -49,6 +77,60 @@ 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 = []
@@ -65,7 +147,8 @@ 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)
@@ -223,25 +306,95 @@ 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):
# 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 btn_col2.button("☐ Keine auswählen", use_container_width=True):
if employee_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):
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 = []
@@ -276,7 +429,20 @@ if generate_clicked:
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}
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)
@@ -289,6 +455,7 @@ if generate_clicked:
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:
@@ -320,3 +487,10 @@ if "generated_files" in st.session_state and st.session_state.generated_files:
help="Ordner im Finder ö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,
)
File renamed without changes.
View File
File renamed without changes.
+1
View File
@@ -113,6 +113,7 @@ def main():
mapping=config.output_mapping,
output_dir=output_dir,
filename_pattern=config.filename_pattern,
fontsize=config.fontsize,
)
output_file = generated[0]
File renamed without changes.
File renamed without changes.
@@ -6,6 +6,10 @@ output_dir = output
[output]
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
[font]
# Statische Schriftgröße (in Punkt) für alle Felder im PDF
fontsize = 9
[parsing]
# Sheets whose names match these patterns are treated as monthly data sheets
# Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen").
File renamed without changes.
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 {}
output = parser["output"] if "output" in parser else {}
parsing = parser["parsing"] if "parsing" in parser else {}
font = parser["font"] if "font" in parser else {}
return AppConfig(
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??"),
skip_empty_name=parsing.getboolean("skip_empty_name", 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??"
skip_empty_name: bool = True
skip_empty_antragsnummer: bool = True
fontsize: float = 11.0
@dataclass
File renamed without changes.
File renamed without changes.
+62 -62
View File
@@ -67,7 +67,11 @@ def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: s
if source_field == "Monat (MM.JJ)":
return _format_month_for_template(record.monat)
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":
return employee_name
elif source_field == "Antragsnummer":
@@ -101,10 +105,11 @@ def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: s
def _scan_template_fields(
template_path: Path,
mapping: list[OutputMapping],
) -> dict[str, dict]:
fontsize: float,
) -> dict[str, list[dict]]:
"""
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.
"""
doc = fitz.open(template_path)
@@ -115,43 +120,23 @@ def _scan_template_fields(
if m.pdf_field is not None:
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():
found_any = False
occurrences: list[dict] = []
for page_num in range(doc.page_count):
page = doc[page_num]
found = page.search_for(pdf_field)
if found:
field_info[pdf_field] = {
for rect in found:
occurrences.append({
"source_field": source_field,
"page_num": page_num,
"rect": found[0],
"fontsize": 11, # Fallback
"rect": rect,
"fontsize": fontsize,
"fontname": "helv",
}
found_any = True
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"])
})
field_info[pdf_field] = occurrences
doc.close()
return field_info
@@ -166,68 +151,82 @@ def _fill_pdf_pages(
record: EmployeeRecord,
employee_name: str,
mapping: list[OutputMapping],
field_info: dict[str, dict],
field_info: dict[str, list[dict]],
fontsize: float,
) -> None:
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
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_HEIGHT_PT = 120 # Höhe der Textbox in Punkt
for m in mapping:
if m.pdf_field is None:
continue
info = field_info.get(m.pdf_field)
if info is None:
occurrences = field_info.get(m.pdf_field, [])
if not occurrences:
continue # Feld nicht im Template gefunden
value = _get_field_value(record, employee_name, m.source_field)
formatted = _format_value(value, m.format)
# Jedes Vorkommen des Feldes im PDF befüllen
for info in occurrences:
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)
formatted = _format_value(value, m.format)
# Platzhalter immer redacten, auch wenn kein Wert zum Einsetzen da ist
# Platzhalter redacten
page.add_redact_annot(rect)
page.apply_redactions()
if not formatted:
continue
# Mehrzeilige Felder: insert_textbox mit Zeilenumbruch
# Mehrzeilige Felder: Zeilenweise per insert_text schreiben
if m.pdf_field in MULTILINE_FIELDS:
# Doppelte Leerzeilen entfernen
# ... (unverändert)
clean_text = "\n".join(
line for line in formatted.split("\n") if line.strip()
)
if not clean_text:
continue
# Rechteck für die Textbox: ausgehend vom Marker, nach rechts & unten erweitert
textbox_rect = fitz.Rect(
rect.x0, # links
rect.y0, # oben
rect.x0 + TEXTBOX_WIDTH_PT, # rechts
rect.y0 + TEXTBOX_HEIGHT_PT, # unten
)
page.insert_textbox(
textbox_rect,
clean_text,
fontname="helv",
fontsize=fontsize * TEXTBOX_FONT_SCALE,
lineheight=1.5,
align=fitz.TEXT_ALIGN_LEFT,
)
lines = clean_text.split("\n")
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.bl,
(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],
output_dir: Path,
filename_pattern: str,
fontsize: float = 11.0,
) -> list[Path]:
"""
Erzeugt für jeden Mitarbeiter ein PDF.
@@ -253,7 +253,7 @@ def generate_activity_reports(
generated_files: list[Path] = []
# 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():
if not records:
@@ -276,7 +276,7 @@ def generate_activity_reports(
temp_doc = fitz.open(template_path)
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
result_doc.insert_pdf(temp_doc)
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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
+46
View File
@@ -0,0 +1,46 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
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] Build fehlgeschlagen!
pause
exit /b 1
)
echo.
echo Build abgeschlossen.
pause
+377
View File
@@ -0,0 +1,377 @@
"""Build-Orchestrierung mit Semantic Versioning.
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
Benutzereingabe (major/minor/patch), aktualisiert das Changelog
und baut via PyInstaller eine standalone .exe nach 2_build/.
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
# ── 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 _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 _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)
# Kopiere config-Dateien, falls noch nicht vorhanden
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() and not (config_dst / f.name).exists():
shutil.copy2(f, config_dst / f.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",
)
parser.add_argument(
"--bump",
choices=["major", "minor", "patch"],
help="Version bump-Typ (ohne Angabe: interaktiv)",
)
parser.add_argument(
"--message", "-m",
help="Changelog-Beschreibung für diese Version",
)
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:
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:
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 = bump_version(current, bump_type)
print(f"🚀 Neue Version: {new_version}")
if args.dry_run:
print("\n[Trockenlauf – kein Build]")
print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]")
print(f" Würde PyInstaller-Build starten")
return
# ── CHANGELOG aktualisieren ──────────────────────────────
print("\n📝 Changelog …")
update_changelog(new_version, bump_type, args.message)
# ── Build ────────────────────────────────────────────────
_ensure_pyinstaller()
_prepare_build_dir()
try:
exe_path = _run_pyinstaller()
final_exe = _copy_to_build(exe_path)
finally:
_cleanup()
_save_version(new_version)
_print_summary(new_version, final_exe)
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
# -*- 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 / "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,
)
+96
View File
@@ -0,0 +1,96 @@
"""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 sys
import threading
import time
import webbrowser
from pathlib import Path
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:
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()
Binary file not shown.