12 Commits
Author SHA1 Message Date
RobandClaude Opus 5.5 cb059ac20d Geführtes Update: "Anwendung ersetzen" im Update-Hinweis
Der Update-Hinweis bietet neben "Update Ordner öffnen" nun den Button
"Anwendung ersetzen". Er kopiert das ZIP vom SMB-Laufwerk lokal, prüft es,
entpackt es in einen eigenen Ordner neben der aktuellen Installation,
übernimmt input/, output/ und eigene Templates und startet die neue EXE.
Die alte Installation bleibt als Rückfallebene erhalten; eine angepasste
settings.ini wird als settings.vorherige-version.ini aufbewahrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:55:12 +01:00
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
29 changed files with 1453 additions and 197 deletions

No files matched your search

+4
View File
@@ -61,3 +61,7 @@ $RECYCLE.BIN/
*.vsix *.vsix
3_Archiv 3_Archiv
2_build 2_build
# Python-Test- und Build-Artefakte
__pycache__/
.pytest_cache/
+44
View File
@@ -7,6 +7,50 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
--- ---
## [1.4] – 2026-11-03
### Hinzugefügt
- automatische Update Funktion
---
## [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 ## [1.3] – 2026-08-11
### Hinzugefügt ### Hinzugefügt
+81 -7
View File
@@ -87,8 +87,27 @@ Mapping von Input-Feldern auf Template-Zellen:
## Tests ## Tests
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale Beim Start über `start.bat` oder die neu gebaute EXE erscheint bei einer bereits
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren 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. den gesamten Generierungs-Workflow.
```bash ```bash
@@ -96,12 +115,67 @@ den gesamten Generierungs-Workflow.
.venv/bin/pip install pytest .venv/bin/pip install pytest
# Tests ausführen # Tests ausführen
.venv/bin/python -m pytest tests/test_e2e.py -v .venv/bin/python -m pytest tests -v
``` ```
Die Tests prüfen: Die Tests prüfen:
- Vorhandensein der Input- und Template-Dateien - Vorhandensein der Input- und Template-Dateien
- Korrekte Parsierung (168 Datensätze, 19 Personen) - Korrekte Parsierung (100 Datensätze, 17 Personen)
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets - Vollständige PDF-Pipeline für die erste Person („Bardaus, Madalina“): fünf Monate
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat, von Januar bis Mai 2026 mit zehn Seiten, Antragsnummer, Name, Stellenanteil,
Funktion, Tätigkeit, Ort, Datum) 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.
+19 -1
View File
@@ -9,6 +9,10 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
## 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
@@ -21,6 +25,20 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
5. **"Tätigkeitsnachweise generieren"** klicken 5. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt - Die generierten Dateien werden im `output/`-Ordner abgelegt
## Update
Liegt eine neuere Version auf dem Laufwerk J:, erscheint oben rechts ein Hinweis.
- **„Anwendung ersetzen“** richtet die neue Version in einem eigenen Ordner neben der
aktuellen ein (z. B. `Tätigkeitsnachweis-Generator v1.4.0`), übernimmt `input/`,
`output/` und eigene Template-Dateien und startet sie. Das folgende Rückfragefenster
mit „Ja“ bestätigen, dann wird die alte Version beendet.
- Die alte Version bleibt als Ordner erhalten und kann gelöscht werden, sobald die neue läuft.
- Wurde `settings.ini` angepasst, liegt die bisherige Datei als
`config/settings.vorherige-version.ini` daneben.
- Desktop-Verknüpfungen zeigen weiterhin auf den alten Ordner und müssen neu angelegt werden.
- **„Update Ordner öffnen“** öffnet das Verzeichnis auf J: für ein manuelles Update.
## Konfiguration ## Konfiguration
Alle Einstellungen liegen im `config/`-Ordner: Alle Einstellungen liegen im `config/`-Ordner:
@@ -54,4 +72,4 @@ 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`
+98 -6
View File
@@ -30,6 +30,15 @@ from src.parser import parse_input_file, group_by_employee, get_unique_antragsnu
from src.validator import validate_input_file, validate_template from src.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports from src.writer import generate_activity_reports
from version_check import UPDATE_DIR, get_latest_version from version_check import UPDATE_DIR, get_latest_version
from update_installer import can_replace_application, install_update, start_application
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): def set_active_employee(employee_name: str):
@@ -83,7 +92,7 @@ try:
local_version, latest_version = get_latest_version() local_version, latest_version = get_latest_version()
current_version_text = f"v{'.'.join(map(str, local_version))}" current_version_text = f"v{'.'.join(map(str, local_version))}"
if latest_version: if latest_version:
available_version, _ = latest_version available_version, update_path = latest_version
version_text = ".".join(map(str, available_version)) version_text = ".".join(map(str, available_version))
st.html( st.html(
f'''<style> f'''<style>
@@ -97,7 +106,7 @@ try:
#update-notice-dismiss:checked + .update-notice {{ 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; }} 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 {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }}
.update-notice__actions--footer {{ min-height:26px; }} .update-notice__actions--footer {{ min-height:60px; }}
.update-notice__button {{ .update-notice__button {{
display:inline-flex; align-items:center; justify-content:center; display:inline-flex; align-items:center; justify-content:center;
width:26px; height:26px; color:#5f4700; background:#fffdf3; width:26px; height:26px; color:#5f4700; background:#fffdf3;
@@ -106,11 +115,12 @@ try:
}} }}
.update-notice__button:hover {{ background:#f6e7b2; }} .update-notice__button:hover {{ background:#f6e7b2; }}
div.st-key-update_notice_open {{ div.st-key-update_notice_open {{
position:fixed; top:8.5rem; right:3.75rem; z-index:10001; width:26px; position:fixed; top:8.5rem; right:calc(1rem + 13px); z-index:10001;
width:264px; gap:6px;
}} }}
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }} div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
div.st-key-update_notice_open div[data-testid="stButton"] button {{ div.st-key-update_notice_open div[data-testid="stButton"] button {{
min-width:200px; min-height:26px; height:26px; padding:0; color:#5f4700; width:100%; min-height:26px; height:26px; padding:0; color:#5f4700;
background:#fffdf3; border:1px solid #d9bd66; border-radius:5px; background:#fffdf3; border:1px solid #d9bd66; border-radius:5px;
}} }}
</style> </style>
@@ -128,6 +138,33 @@ try:
if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"): if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"):
if sys.platform == "win32": if sys.platform == "win32":
os.startfile(str(UPDATE_DIR)) os.startfile(str(UPDATE_DIR))
replace_supported = can_replace_application()
replace_clicked = st.button(
"Anwendung ersetzen 🔄", key="replace_application",
disabled=not replace_supported or st.session_state.get("update_started", False),
help=("Neue Version neben der aktuellen einrichten, input/ und output/ übernehmen "
"und starten. Die aktuelle Version bleibt als Ordner erhalten."
if replace_supported else "Nur in der installierten Windows-Anwendung verfügbar."))
if replace_clicked:
try:
with st.spinner(f"Version v{version_text} wird eingerichtet …"):
result = install_update(update_path, PROJECT_ROOT)
start_application(result.target_dir)
except Exception as exc: # Der umgebende Block verschluckt sonst jeden Fehler.
st.error(f"Update fehlgeschlagen: {exc}\n\n"
"Alternativ das Update manuell aus dem Update-Ordner einrichten.")
else:
st.session_state.update_started = True
details = [f"Neuer Programmordner: `{result.target_dir}`"]
if result.already_installed:
details.append("Die Version war dort bereits eingerichtet und wurde nur gestartet.")
else:
details.append("Die Ordner input/ und output/ wurden übernommen.")
if result.previous_settings:
details.append(f"Die bisherige settings.ini wurde als `{result.previous_settings.name}` aufbewahrt.")
details.append("Bitte im folgenden Fenster bestätigen, dass die alte Version beendet wird. "
"Desktop-Verknüpfungen zeigen noch auf den alten Ordner.")
st.success(f"Version v{version_text} wird gestartet.\n\n" + "\n\n".join(details))
except Exception: except Exception:
pass pass
@@ -155,9 +192,64 @@ col1, col2 = st.columns(2)
with col1: with col1:
st.subheader("1. Input-Datei") st.subheader("1. Input-Datei")
input_files = get_available_input_files(app_config) 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: if not input_files:
st.warning("Keine .xlsx-Dateien im input/-Ordner gefunden.") 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 selected_input = None
st.session_state.parsed_records = []
st.session_state.input_validated = False
st.session_state.last_input_file = None
else: else:
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl) # Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
if len(input_files) == 1: if len(input_files) == 1:
@@ -484,7 +576,7 @@ if "generated_files" in st.session_state and st.session_state.generated_files:
key=f"open_folder_{i}", key=f"open_folder_{i}",
on_click=open_path, on_click=open_path,
args=(str(fpath.parent),), args=(str(fpath.parent),),
help="Ordner im Finder öffnen", help="Ordner im Explorer öffnen",
use_container_width=True, use_container_width=True,
) )
+2
View File
@@ -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
+1 -1
View File
@@ -123,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()
+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)
+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
@@ -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..."
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()
+63 -162
View File
@@ -1,12 +1,11 @@
"""End-to-End-Test: Reale Input-Datei + reales PDF-Template → PDF-Output für erste Person.""" """End-to-end checks using the fixed 2026 workbook and the PDF template."""
import sys import sys
from pathlib import Path from pathlib import Path
import fitz # PyMuPDF import fitz
import pytest import pytest
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
@@ -14,37 +13,22 @@ 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.parser import parse_input_file, group_by_employee, get_unique_employees
from src.writer import generate_activity_reports from src.writer import generate_activity_reports
# ──────────────────────────────────────────────
# Pfade zu den realen Fixture-Dateien
# ──────────────────────────────────────────────
CONFIG_DIR = PROJECT_ROOT / "config" CONFIG_DIR = PROJECT_ROOT / "config"
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx" INPUT_FILE = Path(__file__).resolve().parent / "fixture" / "2026 JM.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf" TEMPLATE_FILE = PROJECT_ROOT / "template" / "06-2026 Tätigkeitsnachweise Stellenförderung.pdf"
def _load_app_config(): @pytest.fixture(scope="module")
"""Lädt die echte App-Konfiguration (Schema, Mapping, Settings).""" def app_config():
config = load_settings(CONFIG_DIR, PROJECT_ROOT) config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR) config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR) config.output_mapping = load_output_mapping(CONFIG_DIR)
return config return config
# ──────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────
@pytest.fixture(scope="module")
def app_config():
"""Reale App-Konfiguration aus config/."""
return _load_app_config()
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def parsed_records(app_config): def parsed_records(app_config):
"""Geparste Records aus der realen Input-Datei.""" assert INPUT_FILE.is_file(), f"Testdatei nicht gefunden: {INPUT_FILE}"
return parse_input_file( return parse_input_file(
filepath=INPUT_FILE, filepath=INPUT_FILE,
schema=app_config.input_schema, schema=app_config.input_schema,
@@ -56,165 +40,82 @@ def parsed_records(app_config):
@pytest.fixture @pytest.fixture
def output_dir(tmp_path): def output_dir(tmp_path):
"""Temporäres Output-Verzeichnis."""
out = tmp_path / "output" out = tmp_path / "output"
out.mkdir() out.mkdir()
return out 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: class TestEndToEndFirstPerson:
"""End-to-End: Reale Input-Datei + reales PDF-Template → nur erste Person."""
def test_input_file_exists(self): def test_input_file_exists(self):
"""Stellt sicher, dass die reale Input-Datei vorhanden ist.""" assert INPUT_FILE.is_file(), f"Testdatei nicht gefunden: {INPUT_FILE}"
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
def test_template_file_exists(self): def test_template_file_exists(self):
"""Stellt sicher, dass das reale PDF-Template vorhanden ist.""" assert TEMPLATE_FILE.is_file(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
assert TEMPLATE_FILE.exists(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
def test_template_has_field_markers(self, app_config): def test_template_has_field_markers(self, app_config):
"""Stellt sicher, dass alle _Field-Platzhalter im PDF-Template vorhanden sind.""" with fitz.open(TEMPLATE_FILE) as doc:
doc = fitz.open(TEMPLATE_FILE) missing = [
missing = [] mapping.pdf_field
for m in app_config.output_mapping: for mapping in app_config.output_mapping
if m.pdf_field is None: if mapping.pdf_field is not None
continue and not any(page.search_for(mapping.pdf_field) for page in doc)
# Suche auf allen Seiten ]
found_any = False assert not missing, f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
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): def test_parse_real_input(self, parsed_records):
"""Validiert das Parsen der realen Input-Datei.""" # Fixed expectations for tests/fixture/2026 JM.xlsx.
# Sollte 168 Einträge über 12 Monate für 19 Personen ergeben assert len(parsed_records) == 100
assert len(parsed_records) == 168, (
f"Erwartet 168 Records, aber {len(parsed_records)} gefunden"
)
employees = get_unique_employees(parsed_records) employees = get_unique_employees(parsed_records)
assert len(employees) == 19, ( assert len(employees) == 17
f"Erwartet 19 Mitarbeiter, aber {len(employees)} gefunden" 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")
# Erste Person alphabetisch: Draßdo, Cornelia def test_full_pipeline_first_person(self, app_config, parsed_records, output_dir):
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) grouped = group_by_employee(parsed_records)
assert len(grouped) == 19 assert len(grouped) == 17
first_person = sorted(grouped)[0]
# 2. Erste Person alphabetisch assert first_person == "Bardaus, Madalina"
first_person = sorted(grouped.keys())[0] assert len(grouped[first_person]) == 5
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( generated = generate_activity_reports(
employee_records=single_person_group, employee_records={first_person: grouped[first_person]},
template_path=TEMPLATE_FILE, template_path=TEMPLATE_FILE,
mapping=app_config.output_mapping, mapping=app_config.output_mapping,
output_dir=output_dir, output_dir=output_dir,
filename_pattern=app_config.filename_pattern, filename_pattern=app_config.filename_pattern,
fontsize=app_config.fontsize,
) )
assert len(generated) == 1
# 4. Ergebnisse prüfen
assert len(generated) == 1, (
f"Erwartet 1 generierte Datei, aber {len(generated)}"
)
output_file = generated[0] output_file = generated[0]
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}" assert output_file.is_file()
assert output_file.suffix == ".pdf" assert output_file.name == "Bardaus_Madalina_Tätigkeitsnachweis.pdf"
with fitz.open(output_file) as doc:
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf" assert doc.page_count == 10 # Five months, two pages per month.
assert output_file.name == erwarteter_name, ( assert "_Field" not in "".join(page.get_text() for page in doc)
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'" # Independently specified month ends include February's 28 days.
) expected_months = [
("01.2026", "31.01.2026"),
# 5. PDF öffnen und Inhalt validieren ("02.2026", "28.02.2026"),
doc = fitz.open(output_file) ("03.2026", "31.03.2026"),
assert doc.page_count == 24, ( ("04.2026", "30.04.2026"),
f"Erwartet 24 Seiten (12 Monate × 2 Seiten), aber {doc.page_count}" ("05.2026", "31.05.2026"),
) ]
for index, (month, month_end) in enumerate(expected_months):
# 6. Prüfe, dass KEINE _Field-Platzhalter mehr im Output sind data_text = doc[index * 2].get_text()
full_text = "" signature_text = doc[index * 2 + 1].get_text()
for page in doc: for value in [
full_text += page.get_text() "100781516", "Bardaus, Madalina", "50,00", month,
assert "_Field" not in full_text, ( "Psychologin", "Psychologische Betreuung",
"Es sind noch _Field-Platzhalter im Output-PDF vorhanden!" ]:
) assert value in data_text, f"{value!r} fehlt für {month}"
assert "100781516" not in signature_text
# 7. Prüfe Seite 0 (Januar, Seite 1 – Stammdaten) assert "Dresden" in signature_text
jan0_text = doc[0].get_text() assert month_end in signature_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()
@@ -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()
@@ -0,0 +1,121 @@
"""Tests für das Einrichten einer neuen Version neben der aktuellen Installation."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import zipfile
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import update_installer
from update_installer import EXE_NAME, UpdateError, install_update
class InstallUpdateTests(unittest.TestCase):
def setUp(self):
self._temporary = tempfile.TemporaryDirectory()
self.root = Path(self._temporary.name).resolve()
self.current = self.root / "Tätigkeitsnachweis-Generator v1.3.3"
for folder in ["input", "output/2026", "config", "template"]:
(self.current / folder).mkdir(parents=True)
(self.current / EXE_NAME).write_bytes(b"old")
(self.current / "input" / "daten.xlsx").write_bytes(b"input")
(self.current / "output" / "2026" / "nachweis.pdf").write_bytes(b"output")
(self.current / "config" / "settings.ini").write_bytes(b"[a]\nx=1\n")
(self.current / "template" / "vorlage.pdf").write_bytes(b"old template")
(self.current / "template" / "eigene.pdf").write_bytes(b"own template")
self.share = self.root / "share"
self.share.mkdir()
def tearDown(self):
self._temporary.cleanup()
def make_zip(self, files=None, version="1.4.0"):
files = files if files is not None else {
EXE_NAME: b"new",
"config/settings.ini": b"[a]\nx=1\n",
"template/vorlage.pdf": b"new template",
"input/": b"", "output/": b"",
}
path = self.share / f"Tätigkeitsnachweis-Generator v{version}.zip"
with zipfile.ZipFile(path, "w") as package:
for name, content in files.items():
package.writestr(name, content)
return path
def test_installs_next_to_current_and_copies_user_data(self):
result = install_update(self.make_zip(), self.current)
target = self.root / "Tätigkeitsnachweis-Generator v1.4.0"
self.assertEqual(result.target_dir, target)
self.assertFalse(result.already_installed)
self.assertIsNone(result.previous_settings)
self.assertEqual((target / EXE_NAME).read_bytes(), b"new")
self.assertEqual((target / "input" / "daten.xlsx").read_bytes(), b"input")
self.assertEqual((target / "output" / "2026" / "nachweis.pdf").read_bytes(), b"output")
self.assertEqual((target / "template" / "vorlage.pdf").read_bytes(), b"new template")
self.assertEqual((target / "template" / "eigene.pdf").read_bytes(), b"own template")
# Die alte Installation bleibt unverändert.
self.assertEqual((self.current / EXE_NAME).read_bytes(), b"old")
self.assertEqual([p.name for p in self.root.iterdir() if p.name.startswith(".")], [])
def test_keeps_changed_settings_next_to_new_ones(self):
(self.current / "config" / "settings.ini").write_text("[a]\nx=2\n", encoding="utf-8")
result = install_update(self.make_zip(), self.current)
config = result.target_dir / "config"
self.assertEqual((config / "settings.ini").read_text(encoding="utf-8"), "[a]\nx=1\n")
self.assertEqual(result.previous_settings, config / "settings.vorherige-version.ini")
self.assertEqual(result.previous_settings.read_text(encoding="utf-8"), "[a]\nx=2\n")
def test_existing_installation_is_only_reused(self):
zip_path = self.make_zip()
install_update(zip_path, self.current)
result = install_update(zip_path, self.current)
self.assertTrue(result.already_installed)
def test_rejects_package_without_exe(self):
with self.assertRaisesRegex(UpdateError, "keine"):
install_update(self.make_zip({"README.md": b"x"}), self.current)
self.assertFalse((self.root / "Tätigkeitsnachweis-Generator v1.4.0").exists())
def test_rejects_path_traversal(self):
with self.assertRaisesRegex(UpdateError, "ungültigen Pfad"):
install_update(self.make_zip({EXE_NAME: b"x", "../evil.txt": b"x"}), self.current)
self.assertFalse((self.root / "evil.txt").exists())
def test_rejects_broken_zip_and_cleans_up(self):
broken = self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip"
broken.write_bytes(b"no zip")
with self.assertRaisesRegex(UpdateError, "keine gültige ZIP"):
install_update(broken, self.current)
self.assertEqual(sorted(p.name for p in self.root.iterdir()), sorted([self.current.name, "share"]))
def test_unreachable_share_gives_vpn_hint(self):
with self.assertRaisesRegex(UpdateError, "VPN"):
install_update(self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip", self.current)
def test_refuses_existing_folder_without_program(self):
(self.root / "Tätigkeitsnachweis-Generator v1.4.0").mkdir()
with self.assertRaisesRegex(UpdateError, "existiert bereits"):
install_update(self.make_zip(), self.current)
class StartApplicationTests(unittest.TestCase):
@unittest.skipUnless(sys.platform == "win32", "Windows-Prozessflags")
def test_starts_new_exe_with_clean_bundle_environment(self):
target = Path("C:/neu")
environment = {"PATH": "x", "APP_BASE_DIR": "alt", "_MEIPASS2": "alt",
"_PYI_APPLICATION_HOME_DIR": "alt", "STREAMLIT_SERVER_PORT": "8501"}
with patch.dict(update_installer.os.environ, environment, clear=True), \
patch.object(update_installer.subprocess, "Popen") as popen:
update_installer.start_application(target)
args, kwargs = popen.call_args
self.assertEqual(args[0], [str(target / EXE_NAME)])
self.assertEqual(kwargs["cwd"], target)
self.assertEqual(kwargs["env"], {"PATH": "x", "PYINSTALLER_RESET_ENVIRONMENT": "1"})
self.assertTrue(kwargs["creationflags"] & subprocess.CREATE_NEW_CONSOLE)
if __name__ == "__main__":
unittest.main()
+137
View File
@@ -0,0 +1,137 @@
"""Richtet eine neue Programmversion neben der aktuellen Installation ein.
Ablauf von "Anwendung ersetzen":
1. ZIP vom SMB-Laufwerk lokal zwischenspeichern und prüfen.
2. In einen Zwischenordner neben der aktuellen Installation entpacken.
3. input/ und output/ sowie eigene Template-PDFs übernehmen.
4. Zwischenordner in "Tätigkeitsnachweis-Generator vX.Y.Z" umbenennen.
5. Neue EXE starten; sie beendet die alte Instanz nach Rückfrage selbst.
Die alte Installation bleibt unverändert als Rückfallebene erhalten.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
USER_DATA_DIRS = ("input", "output")
SETTINGS_FILE = Path("config") / "settings.ini"
class UpdateError(Exception):
"""Fehler mit einer Meldung, die direkt angezeigt werden kann."""
@dataclass
class UpdateResult:
target_dir: Path
already_installed: bool = False
previous_settings: Path | None = None
def can_replace_application() -> bool:
"""Nur die gebaute Windows-EXE kann sich selbst ersetzen."""
return sys.platform == "win32" and bool(getattr(sys, "frozen", False))
def _check_members(package: zipfile.ZipFile) -> None:
names = package.namelist()
for name in names:
path = PurePosixPath(name.replace("\\", "/"))
if path.is_absolute() or ".." in path.parts or ":" in name:
raise UpdateError(f"Das Update-Paket enthält einen ungültigen Pfad: {name}")
if EXE_NAME not in names:
raise UpdateError(f"Das Update-Paket enthält keine {EXE_NAME}.")
if package.testzip() is not None:
raise UpdateError("Das Update-Paket ist beschädigt. Bitte später erneut versuchen.")
def _copy_user_data(current_dir: Path, staging_dir: Path) -> None:
for folder in USER_DATA_DIRS:
source = current_dir / folder
if source.is_dir():
shutil.copytree(source, staging_dir / folder, dirs_exist_ok=True)
# Eigene Templates übernehmen, ohne die mitgelieferten zu überschreiben.
templates = current_dir / "template"
if templates.is_dir():
(staging_dir / "template").mkdir(exist_ok=True)
for path in templates.iterdir():
destination = staging_dir / "template" / path.name
if path.is_file() and not destination.exists():
shutil.copy2(path, destination)
def _keep_previous_settings(current_dir: Path, staging_dir: Path) -> Path | None:
"""Neue settings.ini gilt; abweichende alte Einstellungen bleiben daneben erhalten."""
old, new = current_dir / SETTINGS_FILE, staging_dir / SETTINGS_FILE
if not old.is_file() or (new.is_file() and old.read_bytes() == new.read_bytes()):
return None
new.parent.mkdir(parents=True, exist_ok=True)
backup = new.with_name("settings.vorherige-version.ini")
shutil.copy2(old, backup)
return backup
def install_update(zip_path: Path, current_dir: Path) -> UpdateResult:
"""Entpackt zip_path neben current_dir und übernimmt die Nutzerdaten."""
current_dir = current_dir.resolve()
target_dir = current_dir.parent / zip_path.stem
if target_dir == current_dir:
raise UpdateError("Diese Version ist bereits die laufende Installation.")
if (target_dir / EXE_NAME).is_file():
return UpdateResult(target_dir, already_installed=True)
if target_dir.exists():
raise UpdateError(
f"Der Ordner {target_dir} existiert bereits, enthält aber kein Programm. "
"Bitte den Ordner entfernen oder umbenennen und erneut versuchen.")
staging_dir = current_dir.parent / f".{zip_path.stem}.update"
try:
shutil.rmtree(staging_dir, ignore_errors=True)
with tempfile.TemporaryDirectory(prefix="taetigkeitsnachweis-update-") as temporary:
# Erst lokal kopieren: ein VPN-Abbruch trifft so nur die Kopie.
local_zip = Path(temporary) / zip_path.name
try:
shutil.copyfile(zip_path, local_zip)
except OSError as exc:
raise UpdateError(
"Das Update konnte nicht vom Netzlaufwerk geladen werden. "
"Bitte VPN-Verbindung und Laufwerk J: prüfen.") from exc
try:
with zipfile.ZipFile(local_zip) as package:
_check_members(package)
package.extractall(staging_dir)
except zipfile.BadZipFile as exc:
raise UpdateError("Das Update-Paket ist keine gültige ZIP-Datei.") from exc
_copy_user_data(current_dir, staging_dir)
previous_settings = _keep_previous_settings(current_dir, staging_dir)
staging_dir.rename(target_dir)
except UpdateError:
shutil.rmtree(staging_dir, ignore_errors=True)
raise
except OSError as exc:
shutil.rmtree(staging_dir, ignore_errors=True)
raise UpdateError(
f"Die neue Version konnte nicht eingerichtet werden ({exc}). "
f"Bitte Schreibrechte für {current_dir.parent} prüfen.") from exc
if previous_settings is not None:
previous_settings = target_dir / SETTINGS_FILE.parent / previous_settings.name
return UpdateResult(target_dir, previous_settings=previous_settings)
def start_application(target_dir: Path) -> None:
"""Startet die neue EXE in einem eigenen Konsolenfenster wie per Doppelklick."""
env = {key: value for key, value in os.environ.items()
if key != "APP_BASE_DIR" and not key.startswith(("STREAMLIT_", "_MEIPASS", "_PYI_"))}
# Sonst übernimmt die neue EXE Pfade aus dem Bundle der laufenden alten EXE.
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
subprocess.Popen(
[str(target_dir / EXE_NAME)], cwd=target_dir, env=env, close_fds=True,
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP,
)
+1 -1
View File
@@ -1 +1 @@
1.3 1.4
+3 -2
View File
@@ -1,6 +1,7 @@
@echo off @echo off
chcp 65001 >nul chcp 65001 >nul
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
set "PYTHONUTF8=1"
cd /d "%~dp0" cd /d "%~dp0"
cd .. cd ..
@@ -36,11 +37,11 @@ REM ── Build ausführen ─────────────────
if errorlevel 1 ( if errorlevel 1 (
echo. echo.
echo [FEHLER] Build fehlgeschlagen! echo [FEHLER] Pipeline fehlgeschlagen. Details stehen oben.
pause pause
exit /b 1 exit /b 1
) )
echo. echo.
echo Build abgeschlossen. echo Tests, Build und Deployment abgeschlossen.
pause pause
+95 -15
View File
@@ -2,7 +2,8 @@
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
Benutzereingabe (major/minor/patch), aktualisiert das Changelog Benutzereingabe (major/minor/patch), aktualisiert das Changelog
und baut via PyInstaller eine standalone .exe nach 2_build/. 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: Verwendung:
python build.py # interaktiver Modus python build.py # interaktiver Modus
@@ -20,6 +21,8 @@ from datetime import date
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from deploy import deploy, is_template_pdf, write_release_manifest
# ── Pfade ─────────────────────────────────────────────────────── # ── Pfade ───────────────────────────────────────────────────────
BUILD_PIPELINE_DIR = Path(__file__).resolve().parent BUILD_PIPELINE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BUILD_PIPELINE_DIR.parent PROJECT_ROOT = BUILD_PIPELINE_DIR.parent
@@ -141,6 +144,20 @@ def update_changelog(
print(f" ✓ CHANGELOG.md aktualisiert → [{new_version}]") 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: def _build_sections(bump_type: str, message: Optional[str]) -> str:
"""Baut die Sektionen für den Changelog-Eintrag.""" """Baut die Sektionen für den Changelog-Eintrag."""
if message: if message:
@@ -177,6 +194,21 @@ def _build_sections(bump_type: str, message: Optional[str]) -> str:
# ── Build ─────────────────────────────────────────────────────── # ── 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: def _ensure_pyinstaller() -> None:
"""Stellt sicher, dass PyInstaller installiert ist.""" """Stellt sicher, dass PyInstaller installiert ist."""
try: try:
@@ -196,14 +228,27 @@ def _prepare_build_dir() -> None:
target = BUILD_OUTPUT_DIR / folder target = BUILD_OUTPUT_DIR / folder
target.mkdir(exist_ok=True) target.mkdir(exist_ok=True)
# Kopiere config-Dateien, falls noch nicht vorhanden # Liefere die zum aktuellen Quellstand gehörende Konfiguration aus.
config_src = APP_DIR / "config" config_src = APP_DIR / "config"
config_dst = BUILD_OUTPUT_DIR / "config" config_dst = BUILD_OUTPUT_DIR / "config"
if config_src.exists(): if config_src.exists():
for f in config_src.iterdir(): for f in config_src.iterdir():
if f.is_file() and not (config_dst / f.name).exists(): if f.is_file():
shutil.copy2(f, config_dst / f.name) 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 # README für input-Ordner
input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt" input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt"
if not input_readme.exists(): if not input_readme.exists():
@@ -308,15 +353,24 @@ def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Build mit Semantic Versioning", description="Build mit Semantic Versioning",
) )
parser.add_argument( version_options = parser.add_mutually_exclusive_group()
version_options.add_argument(
"--bump", "--bump",
choices=["major", "minor", "patch"], choices=["major", "minor", "patch"],
help="Version bump-Typ (ohne Angabe: interaktiv)", 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( parser.add_argument(
"--message", "-m", "--message", "-m",
help="Changelog-Beschreibung für diese Version", 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( parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
@@ -327,6 +381,8 @@ def main() -> None:
# ── Version ermitteln ──────────────────────────────────── # ── Version ermitteln ────────────────────────────────────
current = get_current_version() current = get_current_version()
if not current: 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.") print("❌ Keine Version in CHANGELOG.md gefunden. Beginne mit 0.1.")
current = "0.1" current = "0.1"
@@ -334,7 +390,7 @@ def main() -> None:
# Bump-Typ bestimmen # Bump-Typ bestimmen
bump_type = args.bump bump_type = args.bump
if not bump_type: if not bump_type and not args.no_bump:
print("\nWelche Art von Änderung?") print("\nWelche Art von Änderung?")
print(" [1] major – inkompatible API-Änderungen") print(" [1] major – inkompatible API-Änderungen")
print(" [2] minor – neue Funktionalität (abwärtskompatibel)") print(" [2] minor – neue Funktionalität (abwärtskompatibel)")
@@ -346,32 +402,56 @@ def main() -> None:
print("\nAbgebrochen.") print("\nAbgebrochen.")
sys.exit(0) sys.exit(0)
new_version = bump_version(current, bump_type) new_version = current if args.no_bump else bump_version(current, bump_type)
print(f"🚀 Neue Version: {new_version}") print(f"🚀 Neue Version: {new_version}")
if args.dry_run: if args.dry_run:
print("\n[Trockenlauf – kein Build]") print("\n[Trockenlauf – kein Build]")
print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]") 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") 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 return
# ── CHANGELOG aktualisieren ────────────────────────────── _run_tests()
print("\n📝 Changelog …")
update_changelog(new_version, bump_type, args.message)
# ── Build ────────────────────────────────────────────────
_ensure_pyinstaller()
_prepare_build_dir()
previous_changelog = _read_changelog()
try: 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() exe_path = _run_pyinstaller()
final_exe = _copy_to_build(exe_path) 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: finally:
_cleanup() _cleanup()
_save_version(new_version) _save_version(new_version)
_print_summary(new_version, final_exe) _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__": if __name__ == "__main__":
main() try:
main()
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
print(f"[FEHLER] Pipeline abgebrochen: {exc}")
raise SystemExit(1)
+3
View File
@@ -76,6 +76,9 @@ except ImportError:
# ── Data Files ────────────────────────────────────────────────── # ── Data Files ──────────────────────────────────────────────────
datas = [ datas = [
(str(_APP_DIR / "app.py"), "."), (str(_APP_DIR / "app.py"), "."),
(str(_APP_DIR / "version_check.py"), "."),
(str(_APP_DIR / "update_installer.py"), "."),
(str(_APP_DIR / "CHANGELOG.md"), "."),
(str(_APP_DIR / "src"), "src"), (str(_APP_DIR / "src"), "src"),
(str(_APP_DIR / "config"), "config"), (str(_APP_DIR / "config"), "config"),
] ]
+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())
+13
View File
@@ -12,12 +12,17 @@ Dieses Skript:
""" """
import os import os
import subprocess
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
from pathlib import Path 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: def _get_base_dir() -> Path:
"""Verzeichnis der .exe (bzw. des Skripts im Entwicklungsmodus).""" """Verzeichnis der .exe (bzw. des Skripts im Entwicklungsmodus)."""
@@ -44,6 +49,14 @@ def _ensure_dirs(base_dir: Path) -> None:
def main() -> None: 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() base_dir = _get_base_dir()
# Im gebundelten Modus: sys._MEIPASS enthält src/ und app.py # Im gebundelten Modus: sys._MEIPASS enthält src/ und app.py