- semantic versioning - auto update on build - git hash zusätzlich - Anzeige per cli und im Fenster
844 lines
31 KiB
Python
844 lines
31 KiB
Python
# 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("<FocusOut>", lambda _: self._on_creation_date_changed())
|
||
self._entry_creation_date.bind("<Return>", 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(
|
||
"<Enter>", lambda event: self._show_tooltip(event, out_label)
|
||
)
|
||
self._lbl_output_path.bind("<Leave>", lambda _: self._hide_tooltip())
|
||
self._lbl_output_path.bind(
|
||
"<Motion>", 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("<Button-1>", 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("<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 _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()
|