add GUI with customtkinter and build to windows executable
This commit is contained in:
1 parent
34e9aa49d6
commit
6e9d865a9d
7 files changed
+757
No files matched your search
@@ -0,0 +1,74 @@
|
|||||||
|
# Copilot Plan: Windows GUI für Anwesenheit PDF Generator
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Eine Windows-GUI mit customtkinter als zweiter Entry-Point (`gui.py`) neben dem
|
||||||
|
bestehenden CLI (`generate.py`). Die GUI wrappt direkt die vorhandenen `src/`-Module
|
||||||
|
und verändert keine Business Logic.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
gui.py # GUI Entry-Point (customtkinter)
|
||||||
|
scripts/
|
||||||
|
create_icon.py # Einmaliges Script: erzeugt assets/icon.ico mit Pillow
|
||||||
|
assets/
|
||||||
|
icon.ico # Generiertes Icon (committed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Bestehende Dateien die erweitert werden:
|
||||||
|
```
|
||||||
|
requirements.txt # + customtkinter>=5.2.0
|
||||||
|
requirements-dev.txt # + pyinstaller>=6.0.0
|
||||||
|
.gitignore # + build/, AnwesenheitPDF.spec, AnwesenheitPDF.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Nicht anzufassen:
|
||||||
|
```
|
||||||
|
generate.py, src/*.py, tests/, config.json, schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenfluss
|
||||||
|
|
||||||
|
```
|
||||||
|
GUI-Eingabe (Datei, Monat)
|
||||||
|
→ der Ordner Input wird gescannt, befindet sich eine Exceldatei darin, wird diese als input verwendet
|
||||||
|
→ der Ordner template wird gescannt und eine darin befindliche pdf als template datei verwendet
|
||||||
|
→ validate() [src/validators.py]
|
||||||
|
→ Vorschau-Panel aktualisieren
|
||||||
|
→ Default wird der Ordner /output als zeil für die generierten PDFs verwendet.
|
||||||
|
→ Sind schon Dateien in output ist eine Hinweis in der GUI zu sehen und vor dem Start der generierung kommt ein Hinweis (im Hinweis kann der Inhalt des Ordners gelöscht werden)
|
||||||
|
→ für eine gewählte Exceldatei + Templatedatei werden automatisch die Preview zeilen angezeigt so wie in show_preview_and_confirm()
|
||||||
|
|
||||||
|
GUI-Bestätigung (Generieren)
|
||||||
|
→ extract_persons() [src/excel_reader.py]
|
||||||
|
→ extract_year_from_filename() [src/excel_reader.py]
|
||||||
|
→ für jede Person:
|
||||||
|
extract_attendance() [src/excel_reader.py]
|
||||||
|
fill_pdf() [src/pdf_filler.py]
|
||||||
|
→ Log-Panel: [OK] / [FEHLER] pro Person
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entwurfsentscheidungen
|
||||||
|
|
||||||
|
- Framework: **customtkinter** (modern, leichtgewichtig, Windows-nativ wirkend)
|
||||||
|
- CLI (`generate.py`) bleibt vollständig unverändert
|
||||||
|
- `config.json` bleibt manuell bearbeitbar — kein Settings-Tab in der GUI
|
||||||
|
- `show_preview_and_confirm()` aus `validators.py` wird **nicht** genutzt (CLI-only)
|
||||||
|
- `_resource_path()` Hilfsfunktion für PyInstaller-Kompatibilität (`sys._MEIPASS`)
|
||||||
|
- Validierung + Generierung laufen in `threading.Thread` (GUI bleibt responsiv)
|
||||||
|
- GUI-Updates aus Threads nur via `self.after(0, callback)` (thread-safe)
|
||||||
|
- `.exe` landet direkt im Root-Verzeichnis (kein `dist/`-Unterordner)
|
||||||
|
- Icon: Pillow-generierter Placeholder (`assets/icon.ico`)
|
||||||
|
|
||||||
|
## Packaging (kein Installer)
|
||||||
|
|
||||||
|
Build-Kommando (einmalig, nach Abschluss der Implementierung):
|
||||||
|
|
||||||
|
```
|
||||||
|
pyinstaller --onefile --windowed --distpath . --icon assets/icon.ico ^
|
||||||
|
--add-data "template;template" --add-data "config.json;." ^
|
||||||
|
--add-data "schema.json;." --add-data "assets;assets" ^
|
||||||
|
--name "AnwesenheitPDF" gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis: `AnwesenheitPDF.exe` nicht direkt im Projekt-Root, sondern mit allem was zum build der Exe benötigt wird im ordner /build und alles was zum ausführen der Exebenötigt wird im Ordner /dist.
|
||||||
@@ -3,6 +3,11 @@ earlier_versions/
|
|||||||
output
|
output
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
# PyInstaller build artifacts
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
AnwesenheitPDF.spec
|
||||||
|
|
||||||
# Fixture-Testdaten (personenbezogene Daten — nicht versioniert)
|
# Fixture-Testdaten (personenbezogene Daten — nicht versioniert)
|
||||||
tests/fixtures/**/*.xlsx
|
tests/fixtures/**/*.xlsx
|
||||||
tests/fixtures/**/*.xlsm
|
tests/fixtures/**/*.xlsm
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 153 B |
@@ -0,0 +1,54 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build-Script fuer AnwesenheitPDF.exe
|
||||||
|
REM
|
||||||
|
REM Ergebnis:
|
||||||
|
REM build/ - temporaere PyInstaller-Artefakte
|
||||||
|
REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten)
|
||||||
|
REM
|
||||||
|
REM Voraussetzungen:
|
||||||
|
REM pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === AnwesenheitPDF Build ===
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/2] Abhaengigkeiten installieren...
|
||||||
|
pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [FEHLER] pip install fehlgeschlagen.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [2/2] Build starten...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
python -m PyInstaller ^
|
||||||
|
--noconfirm ^
|
||||||
|
--onedir ^
|
||||||
|
--windowed ^
|
||||||
|
--distpath dist ^
|
||||||
|
--workpath build ^
|
||||||
|
--specpath build ^
|
||||||
|
--icon "%~dp0assets\icon.ico" ^
|
||||||
|
--add-data "%~dp0template;template" ^
|
||||||
|
--add-data "%~dp0config.json;." ^
|
||||||
|
--add-data "%~dp0schema.json;." ^
|
||||||
|
--add-data "%~dp0assets;assets" ^
|
||||||
|
--name "AnwesenheitPDF" ^
|
||||||
|
gui.py
|
||||||
|
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo [FEHLER] Build fehlgeschlagen.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [OK] Build abgeschlossen.
|
||||||
|
echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
# Build: build.bat ausfuehren (aus dem Projekt-Root).
|
||||||
|
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
|
||||||
|
# Build-Artefakte: build/ (temporaer, nicht committen)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
from tkinter import messagebox, filedialog
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from src.config_loader import load_config
|
||||||
|
from src.excel_reader import (
|
||||||
|
extract_attendance,
|
||||||
|
extract_persons,
|
||||||
|
extract_year_from_filename,
|
||||||
|
)
|
||||||
|
from src.pdf_filler import fill_pdf
|
||||||
|
from src.validators import validate
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resource_path(relative: str) -> Path:
|
||||||
|
"""Absolute path to a bundled asset. Handles PyInstaller onefile mode."""
|
||||||
|
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||||
|
return Path(sys._MEIPASS) / relative
|
||||||
|
return Path(__file__).parent / relative
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_dir() -> Path:
|
||||||
|
"""Root directory for runtime folders (input/, output/, template/)."""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys.executable).parent
|
||||||
|
return Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_input_folder() -> Optional[Path]:
|
||||||
|
"""Return the single .xlsx in input/, or None if 0 or >1 found."""
|
||||||
|
folder = _runtime_dir() / "input"
|
||||||
|
if not folder.exists():
|
||||||
|
return None
|
||||||
|
files = list(folder.glob("*.xlsx"))
|
||||||
|
return files[0] if len(files) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_template_folder() -> Optional[Path]:
|
||||||
|
"""Return the single .pdf in template/, or None if 0 or >1 found."""
|
||||||
|
folder = _runtime_dir() / "template"
|
||||||
|
if not folder.exists():
|
||||||
|
return None
|
||||||
|
files = list(folder.glob("*.pdf"))
|
||||||
|
return files[0] if len(files) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _count_output_pdfs(config: Dict[str, Any]) -> int:
|
||||||
|
"""Count .pdf files in the configured output directory."""
|
||||||
|
out_dir = _runtime_dir() / config.get("output_dir", "output")
|
||||||
|
if not out_dir.exists():
|
||||||
|
return 0
|
||||||
|
return len(list(out_dir.glob("*.pdf")))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_month_keys(config: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Return deduplicated month keys from config (preserves insertion order)."""
|
||||||
|
seen: set = set()
|
||||||
|
keys: List[str] = []
|
||||||
|
for k, v in config.get("month_mapping", {}).items():
|
||||||
|
if v not in seen:
|
||||||
|
seen.add(v)
|
||||||
|
keys.append(k)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Startup: load config once
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ctk.set_appearance_mode("System")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_CONFIG: Dict[str, Any] = load_config()
|
||||||
|
_CONFIG_ERROR: Optional[str] = None
|
||||||
|
except Exception as _exc:
|
||||||
|
_CONFIG = {}
|
||||||
|
_CONFIG_ERROR = str(_exc)
|
||||||
|
|
||||||
|
_MONTH_KEYS: List[str] = _build_month_keys(_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AnwesenheitApp(ctk.CTk):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.title("Anwesenheit PDF Generator")
|
||||||
|
self.geometry("720x680")
|
||||||
|
self.resizable(False, False)
|
||||||
|
|
||||||
|
icon_path = _resource_path("assets/icon.ico")
|
||||||
|
if icon_path.exists():
|
||||||
|
self.iconbitmap(str(icon_path))
|
||||||
|
|
||||||
|
# Internal state
|
||||||
|
self._excel_path: Optional[Path] = None
|
||||||
|
self._template_path: Optional[Path] = None
|
||||||
|
self._out_dir: Optional[Path] = None
|
||||||
|
|
||||||
|
# Build UI sections
|
||||||
|
self._build_input_section()
|
||||||
|
self._build_button_section()
|
||||||
|
self._build_preview_section()
|
||||||
|
self._build_log_section()
|
||||||
|
|
||||||
|
# Auto-scan after event loop starts
|
||||||
|
self.after(100, self._scan_and_validate)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# UI builders
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_input_section(self) -> None:
|
||||||
|
frame = ctk.CTkFrame(self)
|
||||||
|
frame.pack(fill="x", padx=16, pady=(16, 0))
|
||||||
|
frame.columnconfigure(2, weight=1) # value column expands
|
||||||
|
|
||||||
|
BTN_W = 32
|
||||||
|
|
||||||
|
# Row 0: Excel file
|
||||||
|
ctk.CTkLabel(frame, text="Excel-Datei:", width=110, anchor="w").grid(
|
||||||
|
row=0, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
|
)
|
||||||
|
ctk.CTkButton(
|
||||||
|
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||||
|
command=self._browse_excel,
|
||||||
|
).grid(row=0, column=1, padx=(0, 6), pady=6)
|
||||||
|
self._lbl_excel = ctk.CTkLabel(frame, text="Suche\u2026", anchor="w")
|
||||||
|
self._lbl_excel.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="ew")
|
||||||
|
|
||||||
|
# Row 1: Template
|
||||||
|
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
|
||||||
|
row=1, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
|
)
|
||||||
|
ctk.CTkButton(
|
||||||
|
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||||
|
command=self._browse_template,
|
||||||
|
).grid(row=1, column=1, padx=(0, 6), pady=6)
|
||||||
|
self._lbl_template = ctk.CTkLabel(frame, text="Suche\u2026", anchor="w")
|
||||||
|
self._lbl_template.grid(row=1, column=2, padx=(0, 12), pady=6, sticky="ew")
|
||||||
|
|
||||||
|
# Row 2: Month dropdown
|
||||||
|
ctk.CTkLabel(frame, text="Monat:", width=110, anchor="w").grid(
|
||||||
|
row=2, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
|
)
|
||||||
|
self._month_var = ctk.StringVar(value=_MONTH_KEYS[0] if _MONTH_KEYS else "")
|
||||||
|
self._month_menu = ctk.CTkOptionMenu(
|
||||||
|
frame,
|
||||||
|
values=_MONTH_KEYS or ["\u2013"],
|
||||||
|
variable=self._month_var,
|
||||||
|
command=lambda _: self._on_month_changed(),
|
||||||
|
width=150,
|
||||||
|
)
|
||||||
|
self._month_menu.grid(row=2, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||||
|
|
||||||
|
# Row 3: Output dir
|
||||||
|
out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve())
|
||||||
|
ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid(
|
||||||
|
row=3, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
|
)
|
||||||
|
ctk.CTkButton(
|
||||||
|
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||||
|
command=self._open_output_folder,
|
||||||
|
).grid(row=3, column=1, padx=(0, 6), pady=6)
|
||||||
|
ctk.CTkLabel(frame, text=out_label, anchor="w").grid(
|
||||||
|
row=3, column=2, padx=(0, 12), pady=6, sticky="ew"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row 4: Output warning + "Ordner leeren" link (hidden when 0)
|
||||||
|
warning_row = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
|
warning_row.grid(row=4, column=2, padx=(0, 12), pady=(0, 6), sticky="ew")
|
||||||
|
self._lbl_warning = ctk.CTkLabel(
|
||||||
|
warning_row, text="", anchor="w", text_color="orange"
|
||||||
|
)
|
||||||
|
self._lbl_warning.pack(side="left")
|
||||||
|
self._lbl_clear_link = ctk.CTkLabel(
|
||||||
|
warning_row, text="", anchor="w",
|
||||||
|
text_color="#EF4444", cursor="hand2",
|
||||||
|
)
|
||||||
|
self._lbl_clear_link.pack(side="left", padx=(6, 0))
|
||||||
|
self._lbl_clear_link.bind("<Button-1>", lambda _: self._confirm_clear_output())
|
||||||
|
|
||||||
|
def _build_button_section(self) -> None:
|
||||||
|
frame = ctk.CTkFrame(self)
|
||||||
|
frame.pack(fill="x", padx=16, pady=(8, 0))
|
||||||
|
self._btn_generate = ctk.CTkButton(
|
||||||
|
frame,
|
||||||
|
text="Generieren",
|
||||||
|
state="disabled",
|
||||||
|
command=self._start_generate,
|
||||||
|
width=180,
|
||||||
|
height=40,
|
||||||
|
)
|
||||||
|
self._btn_generate.pack(pady=12)
|
||||||
|
|
||||||
|
def _build_preview_section(self) -> None:
|
||||||
|
frame = ctk.CTkFrame(self)
|
||||||
|
frame.pack(fill="x", padx=16, pady=(8, 0))
|
||||||
|
|
||||||
|
ctk.CTkLabel(frame, text="Vorschau", font=ctk.CTkFont(weight="bold")).grid(
|
||||||
|
row=0, column=0, columnspan=4, padx=12, pady=(8, 2), sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
("Datei:", "_pv_datei"),
|
||||||
|
("Jahr:", "_pv_jahr"),
|
||||||
|
("Monat:", "_pv_monat"),
|
||||||
|
("Antragsnummer:", "_pv_antrag"),
|
||||||
|
("Ort:", "_pv_ort"),
|
||||||
|
("Erstelldatum:", "_pv_datum"),
|
||||||
|
("Teilnehmer:", "_pv_teilnehmer"),
|
||||||
|
("Fehler:", "_pv_fehler"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, (text, attr) in enumerate(fields):
|
||||||
|
row = 1 + i // 2
|
||||||
|
col_lbl = (i % 2) * 2
|
||||||
|
col_val = col_lbl + 1
|
||||||
|
ctk.CTkLabel(frame, text=text, width=110, anchor="w").grid(
|
||||||
|
row=row, column=col_lbl, padx=(12, 2), pady=3, sticky="w"
|
||||||
|
)
|
||||||
|
lbl = ctk.CTkLabel(frame, text="–", anchor="w", width=220)
|
||||||
|
lbl.grid(row=row, column=col_val, padx=(0, 8), pady=3, sticky="w")
|
||||||
|
setattr(self, attr, lbl)
|
||||||
|
|
||||||
|
def _build_log_section(self) -> None:
|
||||||
|
frame = ctk.CTkFrame(self)
|
||||||
|
frame.pack(fill="both", expand=True, padx=16, pady=(8, 16))
|
||||||
|
frame.rowconfigure(1, weight=1)
|
||||||
|
frame.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(frame, text="Log", font=ctk.CTkFont(weight="bold")).grid(
|
||||||
|
row=0, column=0, padx=12, pady=(8, 2), sticky="w"
|
||||||
|
)
|
||||||
|
self._log_box = ctk.CTkTextbox(
|
||||||
|
frame,
|
||||||
|
font=ctk.CTkFont(family="Courier New", size=11),
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self._log_box.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="nsew")
|
||||||
|
# Configure clickable-link tag and bind events on underlying tk.Text
|
||||||
|
tb = self._log_box._textbox
|
||||||
|
tb.tag_config("pdf_link", foreground="#4FC3F7", underline=True)
|
||||||
|
tb.bind("<Button-1>", self._on_log_click)
|
||||||
|
tb.bind("<Motion>", self._on_log_motion)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Log helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _log(self, text: str) -> None:
|
||||||
|
self._log_box.configure(state="normal")
|
||||||
|
self._log_box.insert("end", text + "\n")
|
||||||
|
self._log_box.see("end")
|
||||||
|
self._log_box.configure(state="disabled")
|
||||||
|
|
||||||
|
def _log_ok(self, text: str) -> None:
|
||||||
|
"""Insert an [OK] line styled as a clickable PDF link."""
|
||||||
|
tb = self._log_box._textbox
|
||||||
|
tb.configure(state="normal")
|
||||||
|
tb.insert("end", text + "\n")
|
||||||
|
insert_line = int(tb.index("end").split(".")[0]) - 1
|
||||||
|
tb.tag_add("pdf_link", f"{insert_line}.0", f"{insert_line}.end")
|
||||||
|
tb.see("end")
|
||||||
|
tb.configure(state="disabled")
|
||||||
|
|
||||||
|
def _clear_log(self) -> None:
|
||||||
|
self._log_box.configure(state="normal")
|
||||||
|
self._log_box.delete("1.0", "end")
|
||||||
|
self._log_box.configure(state="disabled")
|
||||||
|
|
||||||
|
def _on_log_click(self, event) -> None:
|
||||||
|
tb = self._log_box._textbox
|
||||||
|
idx = tb.index(f"@{event.x},{event.y}")
|
||||||
|
line_start = tb.index(f"{idx} linestart")
|
||||||
|
line_end = tb.index(f"{idx} lineend")
|
||||||
|
line = tb.get(line_start, line_end).strip()
|
||||||
|
if line.startswith("[OK]") and self._out_dir:
|
||||||
|
# Format: "[OK] filename.pdf (i/total)"
|
||||||
|
rest = line[4:].strip()
|
||||||
|
filename = rest.split(" (")[0].strip()
|
||||||
|
pdf_path = self._out_dir / filename
|
||||||
|
if pdf_path.exists():
|
||||||
|
os.startfile(str(pdf_path))
|
||||||
|
|
||||||
|
def _on_log_motion(self, event) -> None:
|
||||||
|
tb = self._log_box._textbox
|
||||||
|
idx = tb.index(f"@{event.x},{event.y}")
|
||||||
|
line_start = tb.index(f"{idx} linestart")
|
||||||
|
line_end = tb.index(f"{idx} lineend")
|
||||||
|
line = tb.get(line_start, line_end).strip()
|
||||||
|
tb.configure(cursor="hand2" if line.startswith("[OK]") else "")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Output folder
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_output_folder(self) -> None:
|
||||||
|
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.startfile(str(out_dir))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Preview helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clear_preview(self) -> None:
|
||||||
|
for attr in (
|
||||||
|
"_pv_datei", "_pv_jahr", "_pv_monat", "_pv_antrag",
|
||||||
|
"_pv_ort", "_pv_datum", "_pv_teilnehmer", "_pv_fehler",
|
||||||
|
):
|
||||||
|
getattr(self, attr).configure(text="–", text_color=("gray10", "gray90"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Output warning
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_output_warning(self) -> None:
|
||||||
|
count = _count_output_pdfs(_CONFIG)
|
||||||
|
if count > 0:
|
||||||
|
self._lbl_warning.configure(
|
||||||
|
text=f"⚠ {count} PDF-Datei(en) bereits in output/"
|
||||||
|
)
|
||||||
|
self._lbl_clear_link.configure(text="(Ordner leeren)")
|
||||||
|
else:
|
||||||
|
self._lbl_warning.configure(text="")
|
||||||
|
self._lbl_clear_link.configure(text="")
|
||||||
|
|
||||||
|
def _confirm_clear_output(self) -> None:
|
||||||
|
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||||
|
count = _count_output_pdfs(_CONFIG)
|
||||||
|
if count == 0:
|
||||||
|
return
|
||||||
|
confirmed = messagebox.askyesno(
|
||||||
|
"Ordner leeren",
|
||||||
|
f"{count} PDF-Datei(en) in output/ l\u00f6schen?\n\nDieser Vorgang kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
|
||||||
|
icon="warning",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
if confirmed:
|
||||||
|
for f in out_dir.glob("*.pdf"):
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._refresh_output_warning()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# File browse helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _browse_excel(self) -> None:
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
title="Excel-Datei ausw\u00e4hlen",
|
||||||
|
filetypes=[("Excel-Dateien", "*.xlsx"), ("Alle Dateien", "*.*")],
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
self._excel_path = Path(path)
|
||||||
|
self._lbl_excel.configure(
|
||||||
|
text=self._excel_path.name, text_color=("gray10", "gray90")
|
||||||
|
)
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
self._clear_preview()
|
||||||
|
self._clear_log()
|
||||||
|
if self._template_path and self._month_var.get():
|
||||||
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
|
def _browse_template(self) -> None:
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
title="Template-PDF ausw\u00e4hlen",
|
||||||
|
filetypes=[("PDF-Dateien", "*.pdf"), ("Alle Dateien", "*.*")],
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
self._template_path = Path(path)
|
||||||
|
self._lbl_template.configure(
|
||||||
|
text=self._template_path.name, text_color=("gray10", "gray90")
|
||||||
|
)
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
self._clear_preview()
|
||||||
|
self._clear_log()
|
||||||
|
if self._excel_path and self._month_var.get():
|
||||||
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_month_changed(self) -> None:
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
self._clear_preview()
|
||||||
|
self._clear_log()
|
||||||
|
self._scan_and_validate()
|
||||||
|
|
||||||
|
def _scan_and_validate(self) -> None:
|
||||||
|
if _CONFIG_ERROR:
|
||||||
|
self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Scan input/
|
||||||
|
self._excel_path = _scan_input_folder()
|
||||||
|
input_folder = _runtime_dir() / "input"
|
||||||
|
if self._excel_path:
|
||||||
|
self._lbl_excel.configure(
|
||||||
|
text=self._excel_path.name, text_color=("gray10", "gray90")
|
||||||
|
)
|
||||||
|
elif not input_folder.exists() or not list(input_folder.glob("*.xlsx")):
|
||||||
|
self._lbl_excel.configure(
|
||||||
|
text="Keine .xlsx in input/ gefunden", text_color="red"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
n = len(list(input_folder.glob("*.xlsx")))
|
||||||
|
self._lbl_excel.configure(
|
||||||
|
text=f"Mehrere Dateien gefunden ({n}) — nur eine erlaubt",
|
||||||
|
text_color="red",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scan template/
|
||||||
|
self._template_path = _scan_template_folder()
|
||||||
|
template_folder = _runtime_dir() / "template"
|
||||||
|
if self._template_path:
|
||||||
|
self._lbl_template.configure(
|
||||||
|
text=self._template_path.name, text_color=("gray10", "gray90")
|
||||||
|
)
|
||||||
|
elif not template_folder.exists() or not list(template_folder.glob("*.pdf")):
|
||||||
|
self._lbl_template.configure(
|
||||||
|
text="Keine .pdf in template/ gefunden", text_color="red"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
n = len(list(template_folder.glob("*.pdf")))
|
||||||
|
self._lbl_template.configure(
|
||||||
|
text=f"Mehrere Dateien gefunden ({n}) — nur eine erlaubt",
|
||||||
|
text_color="red",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh output warning
|
||||||
|
self._refresh_output_warning()
|
||||||
|
|
||||||
|
# Start validation thread if all inputs are ready
|
||||||
|
if self._excel_path and self._template_path and self._month_var.get():
|
||||||
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Validation (background thread)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_validate(self) -> None:
|
||||||
|
excel_file = str(self._excel_path)
|
||||||
|
month = self._month_var.get()
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"errors": [],
|
||||||
|
"year": 0,
|
||||||
|
"persons": [],
|
||||||
|
"month": month,
|
||||||
|
"exception": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["errors"] = validate(excel_file, month)
|
||||||
|
except Exception as exc:
|
||||||
|
result["exception"] = str(exc)
|
||||||
|
self.after(0, self._on_validate_done, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["year"] = extract_year_from_filename(excel_file, _CONFIG)
|
||||||
|
except Exception as exc:
|
||||||
|
result["errors"].append(f"Jahr nicht ermittelbar: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["persons"] = extract_persons(excel_file, month, _CONFIG)
|
||||||
|
except Exception as exc:
|
||||||
|
result["errors"].append(f"Teilnehmer nicht lesbar: {exc}")
|
||||||
|
|
||||||
|
self.after(0, self._on_validate_done, result)
|
||||||
|
|
||||||
|
def _on_validate_done(self, result: Dict[str, Any]) -> None:
|
||||||
|
if result.get("exception"):
|
||||||
|
self._log(f"[FEHLER] {result['exception']}")
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
errors: List[str] = result["errors"]
|
||||||
|
year: int = result["year"]
|
||||||
|
persons = result["persons"]
|
||||||
|
month: str = result["month"]
|
||||||
|
|
||||||
|
month_num = (_CONFIG.get("month_mapping") or {}).get(month, "??")
|
||||||
|
default_color = ("gray10", "gray90")
|
||||||
|
|
||||||
|
self._pv_datei.configure(
|
||||||
|
text=self._excel_path.name if self._excel_path else "–",
|
||||||
|
text_color=default_color,
|
||||||
|
)
|
||||||
|
self._pv_jahr.configure(
|
||||||
|
text=str(year) if year else "–", text_color=default_color
|
||||||
|
)
|
||||||
|
self._pv_monat.configure(
|
||||||
|
text=f"{month} ({month_num})", text_color=default_color
|
||||||
|
)
|
||||||
|
self._pv_antrag.configure(
|
||||||
|
text=_CONFIG.get("antragsnummer", "–"), text_color=default_color
|
||||||
|
)
|
||||||
|
self._pv_ort.configure(
|
||||||
|
text=_CONFIG.get("ort", "–"), text_color=default_color
|
||||||
|
)
|
||||||
|
self._pv_datum.configure(
|
||||||
|
text=date.today().strftime("%d.%m.%Y"), text_color=default_color
|
||||||
|
)
|
||||||
|
self._pv_teilnehmer.configure(
|
||||||
|
text=str(len(persons)), text_color=default_color
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
self._pv_fehler.configure(text=str(len(errors)), text_color="red")
|
||||||
|
self._clear_log()
|
||||||
|
self._log("--- Validierungsfehler ---")
|
||||||
|
for err in errors:
|
||||||
|
self._log(f" [!] {err}")
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
else:
|
||||||
|
self._pv_fehler.configure(text="0", text_color=default_color)
|
||||||
|
self._clear_log()
|
||||||
|
self._btn_generate.configure(state="normal")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Generation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_generate(self) -> None:
|
||||||
|
count = _count_output_pdfs(_CONFIG)
|
||||||
|
if count > 0:
|
||||||
|
answer = messagebox.askyesnocancel(
|
||||||
|
"Ausgabe-Verzeichnis nicht leer",
|
||||||
|
f"{count} PDF-Datei(en) bereits in output/ vorhanden.\n\n"
|
||||||
|
"Inhalt vor der Generierung löschen?",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
if answer is None: # Cancel
|
||||||
|
return
|
||||||
|
if answer: # Yes → delete existing PDFs
|
||||||
|
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||||
|
for f in out_dir.glob("*.pdf"):
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
self._clear_log()
|
||||||
|
threading.Thread(target=self._run_generate, daemon=True).start()
|
||||||
|
|
||||||
|
def _run_generate(self) -> None:
|
||||||
|
excel_file = str(self._excel_path)
|
||||||
|
month = self._month_var.get()
|
||||||
|
success_count = 0
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
year = extract_year_from_filename(excel_file, _CONFIG)
|
||||||
|
persons = extract_persons(excel_file, month, _CONFIG)
|
||||||
|
total = len(persons)
|
||||||
|
|
||||||
|
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._out_dir = out_dir
|
||||||
|
|
||||||
|
for i, person in enumerate(persons, 1):
|
||||||
|
try:
|
||||||
|
attendance = extract_attendance(
|
||||||
|
excel_file, month, person, year, _CONFIG
|
||||||
|
)
|
||||||
|
output_filename = f"{person.filename_safe_name}.pdf"
|
||||||
|
output_path = out_dir / output_filename
|
||||||
|
fill_pdf(attendance, self._template_path, output_path, _CONFIG)
|
||||||
|
self.after(0, self._log_ok, f"[OK] {output_filename} ({i}/{total})")
|
||||||
|
success_count += 1
|
||||||
|
except Exception as exc:
|
||||||
|
self.after(0, self._log, f"[FEHLER] {person.name}: {exc}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self.after(0, self._log, f"[FEHLER] {exc}")
|
||||||
|
self.after(0, self._on_generate_done, 0, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.after(0, self._on_generate_done, success_count, total)
|
||||||
|
|
||||||
|
def _on_generate_done(self, success: int, total: int) -> None:
|
||||||
|
self._log(f"\nFertig: {success}/{total} PDFs erzeugt.")
|
||||||
|
self._btn_generate.configure(state="normal")
|
||||||
|
self._refresh_output_warning()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = AnwesenheitApp()
|
||||||
|
app.mainloop()
|
||||||
@@ -3,3 +3,4 @@ pytest>=7.0.0
|
|||||||
pymupdf>=1.23.0
|
pymupdf>=1.23.0
|
||||||
numpy>=1.26.0
|
numpy>=1.26.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
pyinstaller>=6.0.0
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
openpyxl>=3.1.2
|
openpyxl>=3.1.2
|
||||||
pypdf>=4.0.0
|
pypdf>=4.0.0
|
||||||
click>=8.1.0
|
click>=8.1.0
|
||||||
|
customtkinter>=5.2.0
|
||||||
Reference in new issue
Block a user