# Build: build.bat ausfuehren (aus dem Projekt-Root). # Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten # Build-Artefakte: build/ (temporaer, nicht committen) import os import shutil import sys import threading from datetime import date, datetime from pathlib import Path from typing import Any, Dict, List, Optional import customtkinter as ctk import tkinter as tk 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 _input_dir() -> Path: """Return the input directory and ensure it exists.""" folder = _runtime_dir() / "input" folder.mkdir(parents=True, exist_ok=True) return folder def _list_input_files() -> List[Path]: """Return all Excel files from input/ sorted by name.""" return sorted(_input_dir().glob("*.xlsx"), key=lambda path: path.name.lower()) def _copy_excel_to_input(source: Path) -> Path: """Copy a selected Excel file into input/ and return the local file path.""" target = _input_dir() / source.name if source.resolve() != target.resolve(): shutil.copy2(source, target) return target def _sync_network_excel(config: Dict[str, Any]) -> tuple[Optional[Path], str]: """Copy a newer Excel file from the configured network path into input/.""" source_value = str(config.get("excel_network_source", "")).strip() if not source_value: return None, "Kein Netzlaufwerk-Pfad konfiguriert." source = Path(source_value) if not source.exists(): return None, f"Netzlaufwerk-Datei nicht gefunden: {source.name}" target = _input_dir() / source.name if not target.exists() or source.stat().st_mtime > target.stat().st_mtime: shutil.copy2(source, target) return target, f"Excel-Datei aktualisiert: {source.name}" return target, f"Lokale Excel-Datei ist aktuell: {source.name}" 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 def _truncate_middle(text: str, max_length: int = 48) -> str: """Shorten long text by replacing the middle with '...' .""" if len(text) <= max_length: return text keep = max_length - 3 left = keep // 2 right = keep - left return f"{text[:left]}...{text[-right:]}" def _format_creation_date(value: date) -> str: """Format a creation date for the GUI and PDF preview.""" return value.strftime("%d.%m.%Y") def _parse_creation_date(value: str) -> date: """Parse a German-style date from the GUI entry.""" return datetime.strptime(value.strip(), "%d.%m.%Y").date() # --------------------------------------------------------------------------- # 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__() # Load config and version config = load_config() version = config.get("version", "unknown") self.title(f"Anwesenheit PDF Generator v{version}") 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 self._input_files: List[Path] = [] self._excel_name_to_path: Dict[str, Path] = {} self._tooltip_window: Optional[ctk.CTkToplevel] = None self._tooltip_label: Optional[tk.Label] = None # Build UI sections self._build_input_section() self._build_button_section() self._build_preview_section() self._build_log_section() # Auto-sync after event loop starts self.after(100, self._start_initial_sync) # ------------------------------------------------------------------ # 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._excel_var = ctk.StringVar(value="") self._excel_menu = ctk.CTkOptionMenu( frame, values=["Keine Excel-Datei gefunden"], variable=self._excel_var, command=lambda _: self._on_excel_changed(), width=320, state="disabled", ) self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w") # 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: Creation date ctk.CTkLabel(frame, text="Erstellungsdatum:", width=110, anchor="w").grid( row=3, column=0, padx=(12, 4), pady=6, sticky="w" ) self._creation_date_var = ctk.StringVar(value=_format_creation_date(date.today())) self._entry_creation_date = ctk.CTkEntry( frame, textvariable=self._creation_date_var, width=150, ) self._entry_creation_date.grid(row=3, column=2, padx=(0, 12), pady=6, sticky="w") self._entry_creation_date.bind("", lambda _: self._on_creation_date_changed()) self._entry_creation_date.bind("", lambda _: self._on_creation_date_changed()) # Row 4: Output dir out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve()) ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid( row=4, 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=4, column=1, padx=(0, 6), pady=6) self._lbl_output_path = ctk.CTkLabel( frame, text=_truncate_middle(out_label), anchor="w" ) self._lbl_output_path.grid( row=4, column=2, padx=(0, 12), pady=6, sticky="ew" ) self._lbl_output_path.bind( "", lambda event: self._show_tooltip(event, out_label) ) self._lbl_output_path.bind("", lambda _: self._hide_tooltip()) self._lbl_output_path.bind( "", lambda event: self._move_tooltip(event, out_label) ) # Row 5: Output warning + "Ordner leeren" link (hidden when 0) warning_row = ctk.CTkFrame(frame, fg_color="transparent") warning_row.grid(row=5, 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("", lambda _: self._confirm_clear_output()) self._lbl_download_status = ctk.CTkLabel( frame, text="", anchor="w", text_color="#60A5FA" ) self._lbl_download_status.grid( row=6, column=2, padx=(0, 12), pady=(0, 8), sticky="ew" ) 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"), ("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("", self._on_log_click) tb.bind("", 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 _show_tooltip(self, event, text: str) -> None: if self._tooltip_window is None: self._tooltip_window = tk.Toplevel(self) self._tooltip_window.wm_overrideredirect(True) self._tooltip_window.attributes("-topmost", True) self._tooltip_label = tk.Label( self._tooltip_window, background="#F8FAFC", foreground="#111827", relief="solid", borderwidth=1, padx=10, pady=6, font=("Segoe UI", 9), ) self._tooltip_label.pack() if self._tooltip_label is not None: self._tooltip_label.configure(text=text) self._tooltip_window.geometry(f"+{event.x_root + 14}+{event.y_root + 10}") def _move_tooltip(self, event, text: str) -> None: if self._tooltip_window is None: self._show_tooltip(event, text) return self._tooltip_window.geometry(f"+{event.x_root + 14}+{event.y_root + 10}") def _hide_tooltip(self) -> None: if self._tooltip_window is not None: self._tooltip_window.destroy() self._tooltip_window = None self._tooltip_label = None def _set_excel_loading_state(self, text: str) -> None: self._excel_menu.configure(values=[text], state="disabled") self._excel_var.set(text) def _set_download_status(self, text: str, color: str = "#60A5FA") -> None: self._lbl_download_status.configure(text=text, text_color=color) def _get_creation_date(self) -> date: """Return the parsed creation date from the GUI entry.""" return _parse_creation_date(self._creation_date_var.get()) 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_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 = _copy_excel_to_input(Path(path)) self._refresh_excel_dropdown(selected=self._excel_path) self._btn_generate.configure(state="disabled") self._clear_preview() self._clear_log() self._set_download_status( f"Excel-Datei nach input kopiert: {self._excel_path.name}", "#22C55E", ) 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() def _refresh_excel_dropdown(self, selected: Optional[Path] = None) -> None: self._input_files = _list_input_files() self._excel_name_to_path = {path.name: path for path in self._input_files} if not self._input_files: self._excel_path = None self._excel_menu.configure(values=["Keine Excel-Datei gefunden"], state="disabled") self._excel_var.set("Keine Excel-Datei gefunden") return values = [path.name for path in self._input_files] self._excel_menu.configure(values=values, state="normal") if selected and selected.name in self._excel_name_to_path: selected_name = selected.name elif self._excel_var.get() in self._excel_name_to_path: selected_name = self._excel_var.get() else: selected_name = values[0] self._excel_var.set(selected_name) self._excel_path = self._excel_name_to_path[selected_name] def _start_initial_sync(self) -> None: self._set_excel_loading_state("Lade Excel vom Netzlaufwerk ...") self._set_download_status("Datei vom Netzlaufwerk wird geprueft ...") threading.Thread(target=self._run_initial_sync, daemon=True).start() def _run_initial_sync(self) -> None: try: synced_path, status = _sync_network_excel(_CONFIG) self.after(0, self._finish_initial_sync, synced_path, status, None) except Exception as exc: self.after(0, self._finish_initial_sync, None, "", str(exc)) def _finish_initial_sync( self, synced_path: Optional[Path], status: str, error: Optional[str], ) -> None: if error: self._set_download_status( f"Netzlaufwerk-Pruefung fehlgeschlagen: {error}", "orange", ) else: color = "#22C55E" if synced_path else "orange" self._set_download_status(status, color) self._scan_and_validate(selected_excel=synced_path) def _on_excel_changed(self) -> None: selected = self._excel_name_to_path.get(self._excel_var.get()) self._excel_path = selected self._btn_generate.configure(state="disabled") self._clear_preview() self._clear_log() if self._excel_path and self._template_path and self._month_var.get(): threading.Thread(target=self._run_validate, daemon=True).start() def _on_creation_date_changed(self) -> None: self._btn_generate.configure(state="disabled") self._clear_preview() self._clear_log() if self._excel_path and self._template_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, selected_excel: Optional[Path] = None) -> None: if _CONFIG_ERROR: self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}") return self._refresh_excel_dropdown(selected=selected_excel) # 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() else: self._btn_generate.configure(state="disabled") # ------------------------------------------------------------------ # 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, "creation_date": None, "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}") try: result["creation_date"] = self._get_creation_date() except ValueError: result["errors"].append( "Erstellungsdatum ungueltig. Bitte Format DD.MM.YYYY verwenden." ) 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"] creation_date_value = result["creation_date"] 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_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: try: self._get_creation_date() except ValueError: messagebox.showerror( "Ung\u00fcltiges Datum", "Bitte ein Erstellungsdatum im Format DD.MM.YYYY eingeben.", parent=self, ) self._entry_creation_date.focus_set() return 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) creation_date_value = self._get_creation_date() 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, creation_date=creation_date_value, ) 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()