Compare commits

...
3 Commits
8 changed files with 265 additions and 29 deletions

No files matched your search

+5 -1
View File
@@ -95,4 +95,8 @@ Installer-EXE erstellen, die:
- `{autopf}\AnwesenheitPDF\` (= Program Files, UAC-geschützt) - `{autopf}\AnwesenheitPDF\` (= Program Files, UAC-geschützt)
- Requires admin (PrivilegesRequired=admin) - Requires admin (PrivilegesRequired=admin)
- Startmenü: `{group}\AnwesenheitPDF` - Startmenü: `{group}\AnwesenheitPDF`
- Optional Desktop-Verknüpfung (Checkbox im Wizard)
## Release Notes
- Aktuelle Versionsnummer aus config.json bestaetigen.
- Release-Notes-Dokument im Projekt-Root anlegen.
- Release-Notes-Datei zusaetzlich zum Installer in `update_check_path` kopieren.
+13
View File
@@ -203,6 +203,19 @@ Die deutschen Monatsabkürzungen entsprechen den Tabellenblatt-Namen in der Exce
| `Nov` | November | | `Nov` | November |
| `Dez` | Dezember | | `Dez` | Dezember |
### Sonderfall `P = Praktikum`
Wenn im Excel-Sheet für einen Tag `P` steht, bleibt der Status im PDF `P`, aber die Stundenfelder werden mit 6 Stunden befüllt:
| Feld | Wert |
|---|---|
| `anwesend.N` | `P` |
| `ist.N` | `6,00` |
| `plan.N` | `6` |
| `pauschale_5.N` | `1` |
`P = Praktikum` wird damit wie ein regulärer Anwesenheitstag mit 6 Stunden abgerechnet und zählt auch für die 5-Euro-Pauschale.
--- ---
## Ausgabe ## Ausgabe
+13
View File
@@ -0,0 +1,13 @@
# Release Notes
## Version 1.0.17 (2026-05-21)
### Zusammenfassung
- Lokale Excel-Datei waehlbar ueber Dropdown (input/).
- Neuer Update-Button neben dem Dropdown (nur bei neuerer Quelldatei sichtbar).
- Quelle nutzt nun Netzordner mit mehreren Excel-Dateien.
- Dateiauswahl aus Quelle automatisch: aktuelles Jahr bevorzugt, sonst neuestes Jahr.
- Pruefung auf Updates beim Start und danach alle 60 Sekunden.
- Bei fehlender Verbindung: Warnsymbol in GUI.
- Beim Update: Ueberschreib-Hinweis mit Abbruchoption; nach Bestaetigung Download nach input/ und automatische Auswahl.
- Bugfix Praktikum: Tage mit Status `P` werden in der PDF jetzt wie ein 6h-Anwesenheitstag behandelt, inklusive `mind. 6 h` und Pauschale `8,60`.
+2 -2
View File
@@ -111,9 +111,9 @@ echo.
echo [OK] Installer erstellt: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe echo [OK] Installer erstellt: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe
echo. echo.
echo [5/5] Installer zu update_check_path deployen... echo [5/5] Installer und Release Notes zu update_check_path deployen...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $config = Get-Content -Raw -Encoding UTF8 'config.json' | ConvertFrom-Json; $updatePath = $config.update_check_path; if ([string]::IsNullOrWhiteSpace($updatePath)) { Write-Host '[WARNUNG] update_check_path nicht in config.json gefunden. Deployment uebersprungen.'; exit 0 }; $installer = Join-Path (Get-Location) 'dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe'; if (-not (Test-Path -LiteralPath $installer)) { throw ('Installer nicht gefunden: ' + $installer) }; while (-not (Test-Path -LiteralPath $updatePath)) { Write-Host ''; Write-Host ' +----------------------------------------------------------+'; Write-Host ' | [!] Deployment-Pfad nicht erreichbar |'; Write-Host ' +----------------------------------------------------------+'; Write-Host ( ' | Pfad : {0}' -f $updatePath ); Write-Host ' | |'; Write-Host ' | Bitte Netzlaufwerk / Zielpfad verbinden und dann ENTER |'; Write-Host ' | druecken. |'; Write-Host ' +----------------------------------------------------------+'; [void](Read-Host 'Eingabe'); }; Write-Host ('[OK] Deployment-Pfad erreichbar: {0}' -f $updatePath); Copy-Item -LiteralPath $installer -Destination $updatePath -Force; Write-Host ('[OK] Installer deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $installer -Leaf)))" powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $config = Get-Content -Raw -Encoding UTF8 'config.json' | ConvertFrom-Json; $updatePath = $config.update_check_path; if ([string]::IsNullOrWhiteSpace($updatePath)) { Write-Host '[WARNUNG] update_check_path nicht in config.json gefunden. Deployment uebersprungen.'; exit 0 }; $installer = Join-Path (Get-Location) 'dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe'; $releaseNotes = Join-Path (Get-Location) 'RELEASE_NOTES.md'; if (-not (Test-Path -LiteralPath $installer)) { throw ('Installer nicht gefunden: ' + $installer) }; while (-not (Test-Path -LiteralPath $updatePath)) { Write-Host ''; Write-Host ' +----------------------------------------------------------+'; Write-Host ' | [!] Deployment-Pfad nicht erreichbar |'; Write-Host ' +----------------------------------------------------------+'; Write-Host ( ' | Pfad : {0}' -f $updatePath ); Write-Host ' | |'; Write-Host ' | Bitte Netzlaufwerk / Zielpfad verbinden und dann ENTER |'; Write-Host ' | druecken. |'; Write-Host ' +----------------------------------------------------------+'; [void](Read-Host 'Eingabe'); }; Write-Host ('[OK] Deployment-Pfad erreichbar: {0}' -f $updatePath); Copy-Item -LiteralPath $installer -Destination $updatePath -Force; Write-Host ('[OK] Installer deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $installer -Leaf))); if (Test-Path -LiteralPath $releaseNotes) { Copy-Item -LiteralPath $releaseNotes -Destination $updatePath -Force; Write-Host ('[OK] Release Notes deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $releaseNotes -Leaf))) } else { Write-Host ('[WARNUNG] Release Notes nicht gefunden: {0}' -f $releaseNotes) }"
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo. echo.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.0.15+d07b483", "version": "1.0.17+0d5620a",
"antragsnummer": "100740439", "antragsnummer": "100740439",
"ort": "Dresden", "ort": "Dresden",
"template_pdf": "template/sab.pdf", "template_pdf": "template/sab.pdf",
+197 -21
View File
@@ -107,6 +107,11 @@ def _count_output_pdfs(config: Dict[str, Any]) -> int:
_UPDATE_CHECK_FILE = _runtime_dir() / "update_check.json" _UPDATE_CHECK_FILE = _runtime_dir() / "update_check.json"
_INSTALLER_PATTERN = re.compile(r"^AnwesenheitPDF-Setup-(\d+\.\d+\.\d+)\.exe$", re.IGNORECASE) _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]: def _parse_semver(version_str: str) -> Tuple[int, int, int]:
@@ -173,6 +178,90 @@ def _find_latest_installer(config: Dict[str, Any]) -> Optional[Tuple[str, Path]]
return None 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]: def _build_month_keys(config: Dict[str, Any]) -> List[str]:
"""Return deduplicated month keys from config (preserves insertion order).""" """Return deduplicated month keys from config (preserves insertion order)."""
seen: set = set() seen: set = set()
@@ -248,6 +337,7 @@ class AnwesenheitApp(ctk.CTk):
self._out_dir: Optional[Path] = None self._out_dir: Optional[Path] = None
self._input_files: List[Path] = [] self._input_files: List[Path] = []
self._excel_name_to_path: Dict[str, 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_window: Optional[ctk.CTkToplevel] = None
self._tooltip_label: Optional[tk.Label] = None self._tooltip_label: Optional[tk.Label] = None
@@ -258,8 +348,8 @@ class AnwesenheitApp(ctk.CTk):
self._build_preview_section() self._build_preview_section()
self._build_log_section() self._build_log_section()
# Auto-sync after event loop starts # Initial scan + continuous source check
self.after(100, self._start_initial_sync) self.after(100, self._start_source_excel_watch)
# Daily update check # Daily update check
if _should_check_update(): if _should_check_update():
self.after(200, self._start_update_check) self.after(200, self._start_update_check)
@@ -348,6 +438,21 @@ class AnwesenheitApp(ctk.CTk):
) )
self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w") 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 # Row 1: Template
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid( ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
row=1, column=0, padx=(12, 4), pady=6, sticky="w" row=1, column=0, padx=(12, 4), pady=6, sticky="w"
@@ -557,6 +662,32 @@ class AnwesenheitApp(ctk.CTk):
self._excel_menu.configure(values=[text], state="disabled") self._excel_menu.configure(values=[text], state="disabled")
self._excel_var.set(text) 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: def _set_download_status(self, text: str, color: str = "#60A5FA") -> None:
self._lbl_download_status.configure(text=text, text_color=color) self._lbl_download_status.configure(text=text, text_color=color)
@@ -703,33 +834,78 @@ class AnwesenheitApp(ctk.CTk):
self._excel_var.set(selected_name) self._excel_var.set(selected_name)
self._excel_path = self._excel_name_to_path[selected_name] self._excel_path = self._excel_name_to_path[selected_name]
def _start_initial_sync(self) -> None: def _start_source_excel_watch(self) -> None:
self._set_excel_loading_state("Lade Excel vom Netzlaufwerk ...") self._scan_and_validate()
self._set_download_status("Datei vom Netzlaufwerk wird geprueft ...") self._start_source_excel_check()
threading.Thread(target=self._run_initial_sync, daemon=True).start()
def _run_initial_sync(self) -> None: 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: try:
synced_path, status = _sync_network_excel(_CONFIG) target_file = _copy_excel_to_input(source_file)
self.after(0, self._finish_initial_sync, synced_path, status, None) self.after(0, self._on_source_excel_download_done, target_file, None)
except Exception as exc: except Exception as exc:
self.after(0, self._finish_initial_sync, None, "", str(exc)) self.after(0, self._on_source_excel_download_done, None, str(exc))
def _finish_initial_sync( def _on_source_excel_download_done(
self, self,
synced_path: Optional[Path], target_file: Optional[Path],
status: str,
error: Optional[str], error: Optional[str],
) -> None: ) -> None:
self._btn_excel_update.configure(state="normal", text="Update")
if error: if error:
self._set_download_status( self._set_download_status(f"Update fehlgeschlagen: {error}", "red")
f"Netzlaufwerk-Pruefung fehlgeschlagen: {error}", return
"orange",
) if target_file is None:
else: self._set_download_status("Update fehlgeschlagen.", "red")
color = "#22C55E" if synced_path else "orange" return
self._set_download_status(status, color)
self._scan_and_validate(selected_excel=synced_path) 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: def _on_excel_changed(self) -> None:
selected = self._excel_name_to_path.get(self._excel_var.get()) selected = self._excel_name_to_path.get(self._excel_var.get())
+11 -1
View File
@@ -59,6 +59,8 @@ def build_field_dict(
datum_field: creation_date.strftime("%d.%m.%Y"), datum_field: creation_date.strftime("%d.%m.%Y"),
} }
internship_hours = f"{plan_threshold:.2f}".replace(".", ",")
qualifying_attendance_count: int = 0
pauschale_5_count: int = 0 pauschale_5_count: int = 0
for idx, entry in enumerate(attendance.entries): for idx, entry in enumerate(attendance.entries):
@@ -68,12 +70,20 @@ def build_field_dict(
if entry.is_present: if entry.is_present:
fields[f"ist.{idx}"] = entry.hours_german() fields[f"ist.{idx}"] = entry.hours_german()
fields[f"plan.{idx}"] = plan_value fields[f"plan.{idx}"] = plan_value
if entry.hours is not None and entry.hours >= plan_threshold:
qualifying_attendance_count += 1
# Pauschale only if actual hours >= planned threshold # Pauschale only if actual hours >= planned threshold
if entry.hours is not None and entry.hours >= plan_threshold: if entry.hours is not None and entry.hours >= plan_threshold:
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
pauschale_5_count += 1 pauschale_5_count += 1
else: else:
fields[f"{pauschale_prefix}.{idx}"] = "0" fields[f"{pauschale_prefix}.{idx}"] = "0"
elif entry.status == "P":
fields[f"ist.{idx}"] = internship_hours
fields[f"plan.{idx}"] = plan_value
qualifying_attendance_count += 1
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
pauschale_5_count += 1
else: else:
fields[f"ist.{idx}"] = "" fields[f"ist.{idx}"] = ""
fields[f"plan.{idx}"] = "" fields[f"plan.{idx}"] = ""
@@ -86,7 +96,7 @@ def build_field_dict(
fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}" fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}"
fields["pauschale_25_sum"] = "0.00" fields["pauschale_25_sum"] = "0.00"
fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}" fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}"
fields["anwesend_sum"] = str(pauschale_5_count) fields["anwesend_sum"] = str(qualifying_attendance_count)
return fields return fields
+23 -3
View File
@@ -128,21 +128,30 @@ class TestAbsentDay:
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["anwesend.0"] == status assert fields["anwesend.0"] == status
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
def test_ist_empty(self, cfg, status): def test_ist_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["ist.0"] == "" assert fields["ist.0"] == ""
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
def test_plan_empty(self, cfg, status): def test_plan_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["plan.0"] == "" assert fields["plan.0"] == ""
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
def test_pauschale_empty(self, cfg, status): def test_pauschale_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["pauschale_5.0"] == "0" assert fields["pauschale_5.0"] == "0"
def test_praktikum_sets_fixed_hours(self, cfg):
fields = build_field_dict(_make_attendance(_absent(4, "P")), cfg, date(2026, 2, 1))
assert fields["ist.0"] == "6,00"
assert fields["plan.0"] == "6"
def test_praktikum_sets_pauschale(self, cfg):
fields = build_field_dict(_make_attendance(_absent(4, "P")), cfg, date(2026, 2, 1))
assert fields["pauschale_5.0"] == "1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Multi-row indexing # Multi-row indexing
@@ -177,6 +186,17 @@ class TestSummaryFields:
# Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not # Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not
assert fields["anwesend_sum"] == "2" assert fields["anwesend_sum"] == "2"
def test_anwesend_sum_counts_praktikum_as_6h(self, cfg):
entries = [_present(1, 6.0), _absent(2, "P"), _present(3, 5.0)]
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
assert fields["anwesend_sum"] == "2"
def test_praktikum_increases_pauschale_sum(self, cfg):
entries = [_absent(1, "P")]
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
assert fields["pauschale_5_sum"] == "8.60"
assert fields["pauschale_sum"] == "8.60"
def test_pauschale_5_sum_euro(self, cfg): def test_pauschale_5_sum_euro(self, cfg):
"""pauschale_5_sum = qualifying days * 8.60 (period decimal for JS).""" """pauschale_5_sum = qualifying days * 8.60 (period decimal for JS)."""
entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)] entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)]