From 9d0390f8fdc540b30329686deb10e6c2fbe9cbdc Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 28 Apr 2026 22:17:11 +0200 Subject: [PATCH] Multiple changes - Excel from network folder - multiple input Excel via dowpdown, - free creation date, - Output folder path tooltip --- config.json | 3 +- gui.py | 309 ++++++++++++++++++++++++++++++++++++------- schema.json | 4 + src/config_loader.py | 4 + 4 files changed, 273 insertions(+), 47 deletions(-) diff --git a/config.json b/config.json index 7f471d9..5e75d2e 100644 --- a/config.json +++ b/config.json @@ -31,5 +31,6 @@ "pdf_pauschale_prefix": "pauschale_5", "pauschale_present_value": "1", "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" } diff --git a/gui.py b/gui.py index e4ad5b9..c50f83c 100644 --- a/gui.py +++ b/gui.py @@ -3,13 +3,15 @@ # Build-Artefakte: build/ (temporaer, nicht committen) import os +import shutil import sys import threading -from datetime import date +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)) @@ -42,13 +44,42 @@ def _runtime_dir() -> Path: return Path(__file__).parent -def _scan_input_folder() -> Optional[Path]: - """Return the single .xlsx in input/, or None if 0 or >1 found.""" +def _input_dir() -> Path: + """Return the input directory and ensure it exists.""" folder = _runtime_dir() / "input" - if not folder.exists(): - return None - files = list(folder.glob("*.xlsx")) - return files[0] if len(files) == 1 else None + 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]: @@ -79,6 +110,26 @@ def _build_month_keys(config: Dict[str, Any]) -> List[str]: 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 # --------------------------------------------------------------------------- @@ -117,6 +168,10 @@ class AnwesenheitApp(ctk.CTk): 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() @@ -124,8 +179,8 @@ class AnwesenheitApp(ctk.CTk): self._build_preview_section() self._build_log_section() - # Auto-scan after event loop starts - self.after(100, self._scan_and_validate) + # Auto-sync after event loop starts + self.after(100, self._start_initial_sync) # ------------------------------------------------------------------ # UI builders @@ -146,8 +201,16 @@ class AnwesenheitApp(ctk.CTk): 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") + 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( @@ -174,22 +237,46 @@ class AnwesenheitApp(ctk.CTk): ) 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("", lambda _: self._on_creation_date_changed()) + self._entry_creation_date.bind("", lambda _: self._on_creation_date_changed()) + + # Row 4: Output dir out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve()) ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid( - row=3, column=0, padx=(12, 4), pady=6, sticky="w" + 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=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" + ).grid(row=4, column=1, padx=(0, 6), pady=6) + self._lbl_output_path = ctk.CTkLabel( + frame, text=_truncate_middle(out_label), anchor="w" + ) + self._lbl_output_path.grid( + row=4, column=2, padx=(0, 12), pady=6, sticky="ew" + ) + self._lbl_output_path.bind( + "", lambda event: self._show_tooltip(event, out_label) + ) + self._lbl_output_path.bind("", lambda _: self._hide_tooltip()) + self._lbl_output_path.bind( + "", lambda event: self._move_tooltip(event, out_label) ) - # Row 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.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( 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.bind("", lambda _: self._confirm_clear_output()) + self._lbl_download_status = ctk.CTkLabel( + frame, text="", anchor="w", text_color="#60A5FA" + ) + self._lbl_download_status.grid( + row=6, column=2, padx=(0, 12), pady=(0, 8), sticky="ew" + ) + def _build_button_section(self) -> None: frame = ctk.CTkFrame(self) frame.pack(fill="x", padx=16, pady=(8, 0)) @@ -228,7 +322,6 @@ class AnwesenheitApp(ctk.CTk): ("Monat:", "_pv_monat"), ("Antragsnummer:", "_pv_antrag"), ("Ort:", "_pv_ort"), - ("Erstelldatum:", "_pv_datum"), ("Teilnehmer:", "_pv_teilnehmer"), ("Fehler:", "_pv_fehler"), ] @@ -290,6 +383,51 @@ class AnwesenheitApp(ctk.CTk): 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}") @@ -328,7 +466,7 @@ class AnwesenheitApp(ctk.CTk): def _clear_preview(self) -> None: for attr in ( "_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")) @@ -377,13 +515,15 @@ class AnwesenheitApp(ctk.CTk): ) if not path: return - self._excel_path = Path(path) - self._lbl_excel.configure( - text=self._excel_path.name, text_color=("gray10", "gray90") - ) + 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() @@ -404,6 +544,73 @@ class AnwesenheitApp(ctk.CTk): 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 # ------------------------------------------------------------------ @@ -414,28 +621,12 @@ class AnwesenheitApp(ctk.CTk): self._clear_log() self._scan_and_validate() - def _scan_and_validate(self) -> None: + def _scan_and_validate(self, selected_excel: Optional[Path] = None) -> 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", - ) + self._refresh_excel_dropdown(selected=selected_excel) # Scan template/ self._template_path = _scan_template_folder() @@ -461,6 +652,8 @@ class AnwesenheitApp(ctk.CTk): # 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) @@ -474,6 +667,7 @@ class AnwesenheitApp(ctk.CTk): "year": 0, "persons": [], "month": month, + "creation_date": None, "exception": None, } @@ -494,6 +688,13 @@ class AnwesenheitApp(ctk.CTk): 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: @@ -506,6 +707,7 @@ class AnwesenheitApp(ctk.CTk): 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") @@ -526,9 +728,6 @@ class AnwesenheitApp(ctk.CTk): 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 ) @@ -550,6 +749,17 @@ class AnwesenheitApp(ctk.CTk): # ------------------------------------------------------------------ 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( @@ -581,6 +791,7 @@ class AnwesenheitApp(ctk.CTk): 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") @@ -594,7 +805,13 @@ class AnwesenheitApp(ctk.CTk): ) output_filename = f"{person.filename_safe_name}.pdf" 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})") success_count += 1 except Exception as exc: diff --git a/schema.json b/schema.json index 1472c47..879398a 100644 --- a/schema.json +++ b/schema.json @@ -90,6 +90,10 @@ "type": "boolean", "description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).", "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" } } } diff --git a/src/config_loader.py b/src/config_loader.py index ac93008..c839ee7 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -66,6 +66,10 @@ def load_config(config_path: Path | None = None) -> Dict[str, Any]: # Optional settings with backward-compatible defaults 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_path_cache = config_path