Files
sufw-anweisenheiten/gui.py
T

623 lines
23 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 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()