Files
sufw-anweisenheiten/gui.py
T

1152 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Build: build.bat ausfuehren (aus dem Projekt-Root).
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
# Build-Artefakte: build/ (temporaer, nicht committen)
import json
import os
import re
import shutil
import sys
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
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")))
# ---------------------------------------------------------------------------
# Update-check helpers
# ---------------------------------------------------------------------------
_UPDATE_CHECK_FILE = _runtime_dir() / "update_check.json"
_INSTALLER_PATTERN = re.compile(r"^AnwesenheitPDF-Setup-(\d+\.\d+\.\d+)\.exe$", re.IGNORECASE)
_SOURCE_EXCEL_DIR = Path(
r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\30_Teilnehmende\01_Anwesenheit\Anwesenheitsliste"
)
_SOURCE_YEAR_PATTERN = re.compile(r"(20\d{2})")
_SOURCE_CHECK_INTERVAL_MS = 60_000
def _parse_semver(version_str: str) -> Tuple[int, int, int]:
"""Parse semantic version string, ignoring build metadata after '+'."""
base = version_str.split("+")[0].strip()
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", base)
if not match:
return (0, 0, 0)
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _should_check_update() -> bool:
"""Return True if no check has been recorded for today."""
try:
if _UPDATE_CHECK_FILE.exists():
with open(_UPDATE_CHECK_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("last_check") != date.today().isoformat()
except Exception:
pass
return True
def _record_update_check() -> None:
"""Persist today's date as the last check timestamp."""
try:
with open(_UPDATE_CHECK_FILE, "w", encoding="utf-8") as f:
json.dump({"last_check": date.today().isoformat()}, f)
except Exception:
pass
def _find_latest_installer(config: Dict[str, Any]) -> Optional[Tuple[str, Path]]:
"""Scan the configured SMB update path for the newest installer.
Returns (version_str, installer_path) if a newer version exists, else None.
"""
update_path_str = str(config.get("update_check_path", "")).strip()
if not update_path_str:
return None
update_dir = Path(update_path_str)
if not update_dir.exists():
return None
current_ver = _parse_semver(str(config.get("version", "0.0.0")))
best_ver = current_ver
best_file: Optional[Path] = None
best_ver_str: Optional[str] = None
for f in update_dir.glob("AnwesenheitPDF-Setup-*.exe"):
m = _INSTALLER_PATTERN.match(f.name)
if not m:
continue
ver_str = m.group(1)
ver = _parse_semver(ver_str)
if ver > best_ver:
best_ver = ver
best_file = f
best_ver_str = ver_str
if best_file and best_ver_str:
return (best_ver_str, best_file)
return None
def _extract_year_from_excel_name(filename: str) -> Optional[int]:
"""Extract a four-digit year from an Excel filename."""
match = _SOURCE_YEAR_PATTERN.search(filename)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def _choose_source_excel(files: List[Path]) -> Optional[Path]:
"""Pick the most relevant source Excel (current year preferred)."""
if not files:
return None
current_year = date.today().year
with_year: List[Tuple[Path, int]] = []
without_year: List[Path] = []
for file in files:
year = _extract_year_from_excel_name(file.name)
if year is None:
without_year.append(file)
else:
with_year.append((file, year))
current_year_files = [f for f, y in with_year if y == current_year]
if current_year_files:
return max(current_year_files, key=lambda p: p.stat().st_mtime)
if with_year:
newest_year = max(y for _, y in with_year)
newest_year_files = [f for f, y in with_year if y == newest_year]
return max(newest_year_files, key=lambda p: p.stat().st_mtime)
return max(without_year, key=lambda p: p.stat().st_mtime)
def _check_source_excel_update() -> Dict[str, Any]:
"""Check source folder connectivity and whether a newer Excel is available."""
result: Dict[str, Any] = {
"connected": False,
"update_available": False,
"source_file": None,
"target_file": None,
"status": "",
}
try:
if not _SOURCE_EXCEL_DIR.exists():
result["status"] = "Netzlaufwerk nicht erreichbar."
return result
source_files = sorted(_SOURCE_EXCEL_DIR.glob("*.xlsx"), key=lambda p: p.name.lower())
result["connected"] = True
if not source_files:
result["status"] = "Keine Excel-Datei in der Quelle gefunden."
return result
source_file = _choose_source_excel(source_files)
if source_file is None:
result["status"] = "Keine geeignete Excel-Datei in der Quelle gefunden."
return result
target_file = _input_dir() / source_file.name
result["source_file"] = source_file
result["target_file"] = target_file
if (not target_file.exists()) or (source_file.stat().st_mtime > target_file.stat().st_mtime):
result["update_available"] = True
result["status"] = f"Neue Excel verfügbar: {source_file.name}"
return result
result["status"] = f"Excel aktuell: {source_file.name}"
return result
except OSError:
result["connected"] = False
result["status"] = "Netzlaufwerk nicht erreichbar."
return result
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._pending_source_excel: Optional[Path] = None
self._tooltip_window: Optional[ctk.CTkToplevel] = None
self._tooltip_label: Optional[tk.Label] = None
# Build UI sections
self._build_update_banner()
self._build_input_section()
self._build_button_section()
self._build_preview_section()
self._build_log_section()
# Initial scan + continuous source check
self.after(100, self._start_source_excel_watch)
# Daily update check
if _should_check_update():
self.after(200, self._start_update_check)
# ------------------------------------------------------------------
# UI builders
# ------------------------------------------------------------------
def _build_update_banner(self) -> None:
"""Create the update-available banner (hidden until an update is found)."""
self._update_banner = ctk.CTkFrame(self, fg_color="#854D0E")
# Banner is NOT packed here — only shown when an update is detected
self._update_banner_label = ctk.CTkLabel(
self._update_banner,
text="",
text_color="#FEF9C3",
font=ctk.CTkFont(weight="bold"),
anchor="w",
)
self._update_banner_label.pack(side="left", padx=12, pady=6, fill="x", expand=True)
self._update_banner_btn = ctk.CTkButton(
self._update_banner,
text="Installieren",
width=110,
height=28,
fg_color="#CA8A04",
hover_color="#A16207",
text_color="white",
command=self._open_update_folder,
)
self._update_banner_btn.pack(side="right", padx=(0, 12), pady=6)
self._update_installer_path: Optional[Path] = None
def _show_update_banner(self, version: str, installer: Path) -> None:
"""Display the update banner with the new version info."""
self._update_installer_path = installer
current = _CONFIG.get("version", "?").split("+")[0]
self._update_banner_label.configure(
text=f"Update verfügbar: v{current} → v{version} "
f"({installer.name})"
)
# Insert banner above everything else
self._update_banner.pack(fill="x", padx=16, pady=(8, 0), before=self.winfo_children()[1])
def _open_update_folder(self) -> None:
"""Open the installer directory in Explorer."""
if self._update_installer_path and self._update_installer_path.parent.exists():
os.startfile(str(self._update_installer_path.parent))
def _start_update_check(self) -> None:
threading.Thread(target=self._run_update_check, daemon=True).start()
def _run_update_check(self) -> None:
try:
result = _find_latest_installer(_CONFIG)
except Exception:
result = None
_record_update_check()
if result:
version, installer = result
self.after(0, self._show_update_banner, version, installer)
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")
self._btn_excel_update = ctk.CTkButton(
frame,
text="Update",
width=90,
height=28,
command=self._on_excel_update_click,
)
self._btn_excel_update.grid(row=0, column=3, padx=(0, 6), pady=6, sticky="w")
self._btn_excel_update.grid_remove()
self._lbl_source_warning = ctk.CTkLabel(
frame, text="", width=24, anchor="w", text_color="orange"
)
self._lbl_source_warning.grid(row=0, column=4, 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_source_state(
self,
connected: bool,
update_available: bool,
source_file: Optional[Path],
status: str,
) -> None:
self._pending_source_excel = source_file if update_available else None
if not connected:
self._lbl_source_warning.configure(text="⚠")
self._btn_excel_update.grid_remove()
self._set_download_status(status or "Netzlaufwerk nicht erreichbar.", "orange")
return
self._lbl_source_warning.configure(text="")
if update_available and source_file is not None:
self._btn_excel_update.grid()
self._btn_excel_update.configure(state="normal", text="Update")
self._set_download_status(status, "#60A5FA")
return
self._btn_excel_update.grid_remove()
if status:
self._set_download_status(status, "#22C55E")
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_source_excel_watch(self) -> None:
self._scan_and_validate()
self._start_source_excel_check()
def _start_source_excel_check(self) -> None:
threading.Thread(target=self._run_source_excel_check, daemon=True).start()
def _run_source_excel_check(self) -> None:
result = _check_source_excel_update()
self.after(0, self._on_source_excel_check_done, result)
def _on_source_excel_check_done(self, result: Dict[str, Any]) -> None:
self._set_source_state(
connected=bool(result.get("connected")),
update_available=bool(result.get("update_available")),
source_file=result.get("source_file"),
status=str(result.get("status") or ""),
)
self.after(_SOURCE_CHECK_INTERVAL_MS, self._start_source_excel_check)
def _on_excel_update_click(self) -> None:
source_file = self._pending_source_excel
if source_file is None:
return
target_file = _input_dir() / source_file.name
if target_file.exists():
proceed = messagebox.askokcancel(
"Datei überschreiben",
f"Die vorhandene Datei\n{target_file.name}\n"
"wird durch die neuere Datei ersetzt.\n\nFortfahren?",
icon="warning",
parent=self,
)
if not proceed:
return
self._btn_excel_update.configure(state="disabled", text="Lade...")
self._set_download_status(f"Lade {source_file.name} ...", "#60A5FA")
threading.Thread(
target=self._run_source_excel_download,
args=(source_file,),
daemon=True,
).start()
def _run_source_excel_download(self, source_file: Path) -> None:
try:
target_file = _copy_excel_to_input(source_file)
self.after(0, self._on_source_excel_download_done, target_file, None)
except Exception as exc:
self.after(0, self._on_source_excel_download_done, None, str(exc))
def _on_source_excel_download_done(
self,
target_file: Optional[Path],
error: Optional[str],
) -> None:
self._btn_excel_update.configure(state="normal", text="Update")
if error:
self._set_download_status(f"Update fehlgeschlagen: {error}", "red")
return
if target_file is None:
self._set_download_status("Update fehlgeschlagen.", "red")
return
self._set_download_status(f"Excel aktualisiert: {target_file.name}", "#22C55E")
self._refresh_excel_dropdown(selected=target_file)
self._on_excel_changed()
self._pending_source_excel = None
self._btn_excel_update.grid_remove()
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()