Multiple changes

- Excel from network folder
- multiple input Excel via dowpdown,
- free creation date,
- Output folder path tooltip
This commit is contained in:
Rob committed 2026-04-28 22:17:11 +02:00
1 parent 6e9d865a9d
commit 9d0390f8fd
4 files changed
+273 -47

No files matched your search

+2 -1
View File
@@ -31,5 +31,6 @@
"pdf_pauschale_prefix": "pauschale_5", "pdf_pauschale_prefix": "pauschale_5",
"pauschale_present_value": "1", "pauschale_present_value": "1",
"pdf_use_display_values": true, "pdf_use_display_values": true,
"pdf_need_appearances": false "pdf_need_appearances": false,
"excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx"
} }
+263 -46
View File
@@ -3,13 +3,15 @@
# Build-Artefakte: build/ (temporaer, nicht committen) # Build-Artefakte: build/ (temporaer, nicht committen)
import os import os
import shutil
import sys import sys
import threading import threading
from datetime import date from datetime import date, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import customtkinter as ctk import customtkinter as ctk
import tkinter as tk
from tkinter import messagebox, filedialog from tkinter import messagebox, filedialog
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
@@ -42,13 +44,42 @@ def _runtime_dir() -> Path:
return Path(__file__).parent return Path(__file__).parent
def _scan_input_folder() -> Optional[Path]: def _input_dir() -> Path:
"""Return the single .xlsx in input/, or None if 0 or >1 found.""" """Return the input directory and ensure it exists."""
folder = _runtime_dir() / "input" folder = _runtime_dir() / "input"
if not folder.exists(): folder.mkdir(parents=True, exist_ok=True)
return None return folder
files = list(folder.glob("*.xlsx"))
return files[0] if len(files) == 1 else None
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]: def _scan_template_folder() -> Optional[Path]:
@@ -79,6 +110,26 @@ def _build_month_keys(config: Dict[str, Any]) -> List[str]:
return keys 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 # Startup: load config once
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -117,6 +168,10 @@ class AnwesenheitApp(ctk.CTk):
self._excel_path: Optional[Path] = None self._excel_path: Optional[Path] = None
self._template_path: Optional[Path] = None self._template_path: Optional[Path] = None
self._out_dir: 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 # Build UI sections
self._build_input_section() self._build_input_section()
@@ -124,8 +179,8 @@ class AnwesenheitApp(ctk.CTk):
self._build_preview_section() self._build_preview_section()
self._build_log_section() self._build_log_section()
# Auto-scan after event loop starts # Auto-sync after event loop starts
self.after(100, self._scan_and_validate) self.after(100, self._start_initial_sync)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# UI builders # UI builders
@@ -146,8 +201,16 @@ class AnwesenheitApp(ctk.CTk):
frame, text="\U0001f4c2", width=BTN_W, height=28, frame, text="\U0001f4c2", width=BTN_W, height=28,
command=self._browse_excel, command=self._browse_excel,
).grid(row=0, column=1, padx=(0, 6), pady=6) ).grid(row=0, column=1, padx=(0, 6), pady=6)
self._lbl_excel = ctk.CTkLabel(frame, text="Suche\u2026", anchor="w") self._excel_var = ctk.StringVar(value="")
self._lbl_excel.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="ew") 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 # Row 1: Template
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid( ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
@@ -174,22 +237,46 @@ class AnwesenheitApp(ctk.CTk):
) )
self._month_menu.grid(row=2, column=2, padx=(0, 12), pady=6, sticky="w") self._month_menu.grid(row=2, column=2, padx=(0, 12), pady=6, sticky="w")
# Row 3: Output dir # 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()) out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve())
ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid( ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid(
row=3, column=0, padx=(12, 4), pady=6, sticky="w" row=4, column=0, padx=(12, 4), pady=6, sticky="w"
) )
ctk.CTkButton( ctk.CTkButton(
frame, text="\U0001f4c2", width=BTN_W, height=28, frame, text="\U0001f4c2", width=BTN_W, height=28,
command=self._open_output_folder, command=self._open_output_folder,
).grid(row=3, column=1, padx=(0, 6), pady=6) ).grid(row=4, column=1, padx=(0, 6), pady=6)
ctk.CTkLabel(frame, text=out_label, anchor="w").grid( self._lbl_output_path = ctk.CTkLabel(
row=3, column=2, padx=(0, 12), pady=6, sticky="ew" 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 4: Output warning + "Ordner leeren" link (hidden when 0) # Row 5: Output warning + "Ordner leeren" link (hidden when 0)
warning_row = ctk.CTkFrame(frame, fg_color="transparent") warning_row = ctk.CTkFrame(frame, fg_color="transparent")
warning_row.grid(row=4, column=2, padx=(0, 12), pady=(0, 6), sticky="ew") warning_row.grid(row=5, column=2, padx=(0, 12), pady=(0, 6), sticky="ew")
self._lbl_warning = ctk.CTkLabel( self._lbl_warning = ctk.CTkLabel(
warning_row, text="", anchor="w", text_color="orange" warning_row, text="", anchor="w", text_color="orange"
) )
@@ -201,6 +288,13 @@ class AnwesenheitApp(ctk.CTk):
self._lbl_clear_link.pack(side="left", padx=(6, 0)) self._lbl_clear_link.pack(side="left", padx=(6, 0))
self._lbl_clear_link.bind("<Button-1>", lambda _: self._confirm_clear_output()) 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: def _build_button_section(self) -> None:
frame = ctk.CTkFrame(self) frame = ctk.CTkFrame(self)
frame.pack(fill="x", padx=16, pady=(8, 0)) frame.pack(fill="x", padx=16, pady=(8, 0))
@@ -228,7 +322,6 @@ class AnwesenheitApp(ctk.CTk):
("Monat:", "_pv_monat"), ("Monat:", "_pv_monat"),
("Antragsnummer:", "_pv_antrag"), ("Antragsnummer:", "_pv_antrag"),
("Ort:", "_pv_ort"), ("Ort:", "_pv_ort"),
("Erstelldatum:", "_pv_datum"),
("Teilnehmer:", "_pv_teilnehmer"), ("Teilnehmer:", "_pv_teilnehmer"),
("Fehler:", "_pv_fehler"), ("Fehler:", "_pv_fehler"),
] ]
@@ -290,6 +383,51 @@ class AnwesenheitApp(ctk.CTk):
self._log_box.delete("1.0", "end") self._log_box.delete("1.0", "end")
self._log_box.configure(state="disabled") 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: def _on_log_click(self, event) -> None:
tb = self._log_box._textbox tb = self._log_box._textbox
idx = tb.index(f"@{event.x},{event.y}") idx = tb.index(f"@{event.x},{event.y}")
@@ -328,7 +466,7 @@ class AnwesenheitApp(ctk.CTk):
def _clear_preview(self) -> None: def _clear_preview(self) -> None:
for attr in ( for attr in (
"_pv_datei", "_pv_jahr", "_pv_monat", "_pv_antrag", "_pv_datei", "_pv_jahr", "_pv_monat", "_pv_antrag",
"_pv_ort", "_pv_datum", "_pv_teilnehmer", "_pv_fehler", "_pv_ort", "_pv_teilnehmer", "_pv_fehler",
): ):
getattr(self, attr).configure(text="–", text_color=("gray10", "gray90")) getattr(self, attr).configure(text="–", text_color=("gray10", "gray90"))
@@ -377,13 +515,15 @@ class AnwesenheitApp(ctk.CTk):
) )
if not path: if not path:
return return
self._excel_path = Path(path) self._excel_path = _copy_excel_to_input(Path(path))
self._lbl_excel.configure( self._refresh_excel_dropdown(selected=self._excel_path)
text=self._excel_path.name, text_color=("gray10", "gray90")
)
self._btn_generate.configure(state="disabled") self._btn_generate.configure(state="disabled")
self._clear_preview() self._clear_preview()
self._clear_log() 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(): if self._template_path and self._month_var.get():
threading.Thread(target=self._run_validate, daemon=True).start() threading.Thread(target=self._run_validate, daemon=True).start()
@@ -404,6 +544,73 @@ class AnwesenheitApp(ctk.CTk):
if self._excel_path and self._month_var.get(): if self._excel_path and self._month_var.get():
threading.Thread(target=self._run_validate, daemon=True).start() 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 # Event handlers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -414,28 +621,12 @@ class AnwesenheitApp(ctk.CTk):
self._clear_log() self._clear_log()
self._scan_and_validate() self._scan_and_validate()
def _scan_and_validate(self) -> None: def _scan_and_validate(self, selected_excel: Optional[Path] = None) -> None:
if _CONFIG_ERROR: if _CONFIG_ERROR:
self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}") self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}")
return return
# Scan input/ self._refresh_excel_dropdown(selected=selected_excel)
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/ # Scan template/
self._template_path = _scan_template_folder() self._template_path = _scan_template_folder()
@@ -461,6 +652,8 @@ class AnwesenheitApp(ctk.CTk):
# Start validation thread if all inputs are ready # Start validation thread if all inputs are ready
if self._excel_path and self._template_path and self._month_var.get(): if self._excel_path and self._template_path and self._month_var.get():
threading.Thread(target=self._run_validate, daemon=True).start() threading.Thread(target=self._run_validate, daemon=True).start()
else:
self._btn_generate.configure(state="disabled")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Validation (background thread) # Validation (background thread)
@@ -474,6 +667,7 @@ class AnwesenheitApp(ctk.CTk):
"year": 0, "year": 0,
"persons": [], "persons": [],
"month": month, "month": month,
"creation_date": None,
"exception": None, "exception": None,
} }
@@ -494,6 +688,13 @@ class AnwesenheitApp(ctk.CTk):
except Exception as exc: except Exception as exc:
result["errors"].append(f"Teilnehmer nicht lesbar: {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) self.after(0, self._on_validate_done, result)
def _on_validate_done(self, result: Dict[str, Any]) -> None: def _on_validate_done(self, result: Dict[str, Any]) -> None:
@@ -506,6 +707,7 @@ class AnwesenheitApp(ctk.CTk):
year: int = result["year"] year: int = result["year"]
persons = result["persons"] persons = result["persons"]
month: str = result["month"] month: str = result["month"]
creation_date_value = result["creation_date"]
month_num = (_CONFIG.get("month_mapping") or {}).get(month, "??") month_num = (_CONFIG.get("month_mapping") or {}).get(month, "??")
default_color = ("gray10", "gray90") default_color = ("gray10", "gray90")
@@ -526,9 +728,6 @@ class AnwesenheitApp(ctk.CTk):
self._pv_ort.configure( self._pv_ort.configure(
text=_CONFIG.get("ort", "–"), text_color=default_color 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( self._pv_teilnehmer.configure(
text=str(len(persons)), text_color=default_color text=str(len(persons)), text_color=default_color
) )
@@ -550,6 +749,17 @@ class AnwesenheitApp(ctk.CTk):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _start_generate(self) -> None: 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) count = _count_output_pdfs(_CONFIG)
if count > 0: if count > 0:
answer = messagebox.askyesnocancel( answer = messagebox.askyesnocancel(
@@ -581,6 +791,7 @@ class AnwesenheitApp(ctk.CTk):
try: try:
year = extract_year_from_filename(excel_file, _CONFIG) year = extract_year_from_filename(excel_file, _CONFIG)
persons = extract_persons(excel_file, month, _CONFIG) persons = extract_persons(excel_file, month, _CONFIG)
creation_date_value = self._get_creation_date()
total = len(persons) total = len(persons)
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output") out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
@@ -594,7 +805,13 @@ class AnwesenheitApp(ctk.CTk):
) )
output_filename = f"{person.filename_safe_name}.pdf" output_filename = f"{person.filename_safe_name}.pdf"
output_path = out_dir / output_filename output_path = out_dir / output_filename
fill_pdf(attendance, self._template_path, output_path, _CONFIG) 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})") self.after(0, self._log_ok, f"[OK] {output_filename} ({i}/{total})")
success_count += 1 success_count += 1
except Exception as exc: except Exception as exc:
+4
View File
@@ -90,6 +90,10 @@
"type": "boolean", "type": "boolean",
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).", "description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
"default": false "default": false
},
"excel_network_source": {
"type": "string",
"description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird"
} }
} }
} }
+4
View File
@@ -66,6 +66,10 @@ def load_config(config_path: Path | None = None) -> Dict[str, Any]:
# Optional settings with backward-compatible defaults # Optional settings with backward-compatible defaults
data.setdefault("pdf_need_appearances", False) data.setdefault("pdf_need_appearances", False)
data.setdefault(
"excel_network_source",
r"J:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\30_Teilnehmende\01_Anwesenheit\Anwesenheitsliste\Anwesenheitsliste 2026.xlsx",
)
_config_cache = data _config_cache = data
_config_path_cache = config_path _config_path_cache = config_path