diff --git a/.github/copilot-plan.md b/.github/copilot-plan.md new file mode 100644 index 0000000..c6da7c4 --- /dev/null +++ b/.github/copilot-plan.md @@ -0,0 +1,60 @@ +# Copilot Plan: Attendance PDF Generator + +## Ziel +Kombiniertes Python-CLI-Tool, das Anwesenheitsdaten aus einer Excel-Datei liest +und für jeden Teilnehmer eine ausgefüllte PDF-Datei erzeugt. + +## Architektur + +``` +generate.py # Click CLI entry point +config.json # Konfiguration (antragsnummer, ort, month_mapping, ...) +schema.json # JSON-Schema der Konfiguration (Dokumentation) +requirements.txt # openpyxl, pypdf, click +requirements-dev.txt # + pytest +src/ + __init__.py + exceptions.py # Custom Exception-Hierarchie + models.py # Datenklassen: Person, AttendanceDate, MonthlyAttendance + config_loader.py # config.json laden & validieren + excel_reader.py # Excel → MonthlyAttendance-Objekte + pdf_filler.py # MonthlyAttendance → ausgefüllte PDF + validators.py # Pre-Generation Validierung + Konsolenvorschau +template/ # PDF-Template (sab.pdf, wird separat bereitgestellt) +output/ # Ausgabeverzeichnis für generierte PDFs +tests/ + __init__.py + test_excel_reader.py + test_pdf_filler.py + test_validators.py +``` + +## Datenfluss +Excel-Datei → excel_reader → MonthlyAttendance → pdf_filler → PDF-Datei + +## Entwurfsentscheidungen +- `plan.N` = immer `"6"` (mind. 6h), unabhängig von tatsächlichen Stunden +- Sheet-Auswahl: manuell via `--sheet ` (z.B. `--sheet Feb`) +- Output-Benennung: `(n) Nachname, Vorname.pdf` (n = lfd. Nummer aus Spalte A) +- `pauschale.N` = `"8,60"` wenn anwesend (X), sonst leer +- PDF-Template liegt in `./template/sab.pdf` + +## Excel-Struktur +- Spalte A (Index 0): lfd. Nummer +- Spalte B (Index 1): Name (Format: "Nachname, Vorname") +- Spalten J–AN (Index 9–39): Tageswerte (Tag 1–31) + - Numerischer Wert → anwesend (X), Wert = Ist-Stunden + - "K" → Krank + - "KK" → Kindkrank + - "F" → unentschuldigtes Fehlen + - "E" → entschuldigtes Fehlen + - "P" → Praktikum + - "U" → Urlaub + - Leer → Zeile überspringen + +## PDF-Feld-Mapping (pro Zeile N, max 23 Zeilen) +| Excel-Zelle | datum.N | anwesend.N | ist.N | plan.N | pauschale.N | +|---|---|---|---|---|---| +| Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "8,60" | +| "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | leer | +| Leer | (überspringen) | — | — | — | — | diff --git a/.github/copilot-tasks.md b/.github/copilot-tasks.md new file mode 100644 index 0000000..60fcbfe --- /dev/null +++ b/.github/copilot-tasks.md @@ -0,0 +1,40 @@ +# Copilot Tasks: Attendance PDF Generator + +## Phase 1: Projekt-Setup +- [x] Task 1.1: .github/copilot-plan.md erstellen +- [ ] Task 1.2: requirements.txt + requirements-dev.txt erstellen +- [ ] Task 1.3: config.json erstellen (mit Platzhaltern für antragsnummer, ort) +- [ ] Task 1.4: schema.json erstellen +- [ ] Task 1.5: Verzeichnisse anlegen (src/, output/, template/, tests/) + +## Phase 2: Datenmodell & Konfiguration +- [ ] Task 2.1: src/exceptions.py implementieren +- [ ] Task 2.2: src/models.py implementieren (Person, AttendanceDate, MonthlyAttendance) +- [ ] Task 2.3: src/config_loader.py implementieren + +## Phase 3: Excel Reader +- [ ] Task 3.1: src/excel_reader.py implementieren + - extract_year_from_filename() + - get_sheet_names() + - extract_persons() + - extract_attendance() + +## Phase 4: PDF Filler +- [ ] Task 4.1: src/pdf_filler.py implementieren + - build_field_dict() + - fill_pdf() + +## Phase 5: Validierung & Vorschau +- [ ] Task 5.1: src/validators.py implementieren (9 Checks) +- [ ] Task 5.2: Konsolenvorschau + j/N-Prompt + +## Phase 6: CLI +- [ ] Task 6.1: generate.py mit Click implementieren + - generate --file --sheet [--out] + - validate --file --sheet + +## Phase 7: Tests +- [ ] Task 7.1: tests/test_excel_reader.py +- [ ] Task 7.2: tests/test_pdf_filler.py +- [ ] Task 7.3: tests/test_validators.py +- [ ] Task 7.4: pytest ausführen, Fehler beheben diff --git a/config.json b/config.json new file mode 100644 index 0000000..2b93860 --- /dev/null +++ b/config.json @@ -0,0 +1,30 @@ +{ + "antragsnummer": "BITTE_EINTRAGEN", + "ort": "BITTE_EINTRAGEN", + "template_pdf": "template/sab.pdf", + "output_dir": "output", + "excel_year_pattern": "(19\\d{2}|20\\d{2})", + "excel_name_header": "Name, Vorname", + "excel_name_column": 1, + "excel_seq_column": 0, + "excel_day_start_col": 9, + "excel_day_end_col": 39, + "month_mapping": { + "Jan": "01", + "Feb": "02", + "Mär": "03", + "Mrz": "03", + "Apr": "04", + "Mai": "05", + "Jun": "06", + "Jul": "07", + "Aug": "08", + "Sep": "09", + "Okt": "10", + "Nov": "11", + "Dez": "12" + }, + "valid_status_codes": ["K", "KK", "E", "F", "P", "U"], + "plan_hours_value": "6", + "pauschale_value": "8,60" +} diff --git a/generate.py b/generate.py new file mode 100644 index 0000000..cbbf71e --- /dev/null +++ b/generate.py @@ -0,0 +1,149 @@ +"""CLI entry point for the Attendance PDF Generator. + +Usage +----- + python generate.py generate --file Anwesenheit_2026.xlsx --sheet Feb + python generate.py validate --file Anwesenheit_2026.xlsx --sheet Feb +""" + +import sys +from pathlib import Path + +import click + +# Add project root to sys.path so 'src' is importable +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 show_preview_and_confirm, validate + + +@click.group() +def cli() -> None: + """Anwesenheit PDF Generator — Excel → PDF""" + + +@cli.command("validate") +@click.option( + "--file", + "excel_file", + required=True, + type=click.Path(exists=False, file_okay=True, dir_okay=False), + help="Pfad zur Excel-Datei (z. B. Anwesenheit_2026.xlsx)", +) +@click.option( + "--sheet", + required=True, + help="Name des Monats-Sheets (z. B. Feb, Mär, Jan)", +) +def validate_cmd(excel_file: str, sheet: str) -> None: + """Nur Validierung und Vorschau – keine PDFs werden erzeugt.""" + config = load_config() + errors = validate(excel_file, sheet) + + try: + year = extract_year_from_filename(excel_file, config) + except Exception: + year = 0 + + try: + persons = extract_persons(excel_file, sheet, config) + person_count = len(persons) + except Exception: + person_count = 0 + + show_preview_and_confirm(excel_file, sheet, year, person_count, config, errors) + + if errors: + click.echo("\nValidierung fehlgeschlagen. Keine PDFs erzeugt.") + sys.exit(1) + else: + click.echo("\nValidierung erfolgreich.") + + +@cli.command("generate") +@click.option( + "--file", + "excel_file", + required=True, + type=click.Path(exists=False, file_okay=True, dir_okay=False), + help="Pfad zur Excel-Datei (z. B. Anwesenheit_2026.xlsx)", +) +@click.option( + "--sheet", + required=True, + help="Name des Monats-Sheets (z. B. Feb, Mär, Jan)", +) +@click.option( + "--out", + "output_dir", + required=False, + default=None, + type=click.Path(file_okay=False, dir_okay=True), + help="Ausgabeverzeichnis (Standard: output/ aus config.json)", +) +def generate_cmd(excel_file: str, sheet: str, output_dir: str | None) -> None: + """Liest Excel-Daten und erzeugt eine PDF-Datei pro Teilnehmer.""" + config = load_config() + + # --- Validation ---------------------------------------------------------- + errors = validate(excel_file, sheet) + + try: + year = extract_year_from_filename(excel_file, config) + except Exception as exc: + click.echo(f"Fehler bei Jahresextraktion: {exc}", err=True) + sys.exit(1) + + try: + persons = extract_persons(excel_file, sheet, config) + except Exception as exc: + click.echo(f"Fehler beim Lesen der Teilnehmer: {exc}", err=True) + sys.exit(1) + + # --- Preview + confirmation ---------------------------------------------- + confirmed = show_preview_and_confirm( + excel_file, sheet, year, len(persons), config, errors + ) + if not confirmed: + click.echo("Abgebrochen. Keine PDFs erzeugt.") + sys.exit(0) + + if errors: + click.echo("Generierung abgebrochen – es gibt Validierungsfehler.") + sys.exit(1) + + # --- Resolve paths ------------------------------------------------------- + project_root = Path(__file__).parent + template_path = (project_root / config["template_pdf"]).resolve() + out_dir = Path(output_dir).resolve() if output_dir else (project_root / config["output_dir"]).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + # --- Generate PDFs ------------------------------------------------------- + success_count = 0 + fail_count = 0 + + for person in persons: + try: + attendance = extract_attendance(excel_file, sheet, person, year, config) + output_filename = f"{person.filename_safe_name}.pdf" + output_path = out_dir / output_filename + fill_pdf(attendance, template_path, output_path, config) + click.echo(f" [OK] {output_filename}") + success_count += 1 + except Exception as exc: + click.echo(f" [FEHLER] {person.filename_safe_name}: {exc}", err=True) + fail_count += 1 + + click.echo() + click.echo(f"Fertig: {success_count} PDF(s) erzeugt, {fail_count} Fehler.") + click.echo(f"Ausgabeverzeichnis: {out_dir}") + + if fail_count > 0: + sys.exit(1) + + +if __name__ == "__main__": + cli() diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..17d7648 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=7.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef80721 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +openpyxl>=3.1.2 +pypdf>=4.0.0 +click>=8.1.0 diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..da5a5f8 --- /dev/null +++ b/schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Attendance PDF Generator – Konfiguration", + "description": "Konfigurationsschema für config.json", + "type": "object", + "required": [ + "antragsnummer", + "ort", + "template_pdf", + "output_dir", + "excel_year_pattern", + "excel_name_header", + "excel_name_column", + "excel_seq_column", + "excel_day_start_col", + "excel_day_end_col", + "month_mapping", + "valid_status_codes", + "plan_hours_value", + "pauschale_value" + ], + "properties": { + "antragsnummer": { + "type": "string", + "description": "SAB-Antragsnummer (statisch, aus config)" + }, + "ort": { + "type": "string", + "description": "Ort für die Unterschrift (statisch, aus config)" + }, + "template_pdf": { + "type": "string", + "description": "Relativer Pfad zum PDF-Template" + }, + "output_dir": { + "type": "string", + "description": "Ausgabeverzeichnis für generierte PDFs" + }, + "excel_year_pattern": { + "type": "string", + "description": "Regex-Muster zur Jahresextraktion aus dem Dateinamen" + }, + "excel_name_header": { + "type": "string", + "description": "Text in der Headerzeile der Namensspalte" + }, + "excel_name_column": { + "type": "integer", + "description": "0-basierter Spaltenindex der Namensspalte (Spalte B = 1)" + }, + "excel_seq_column": { + "type": "integer", + "description": "0-basierter Spaltenindex der lfd. Nummer (Spalte A = 0)" + }, + "excel_day_start_col": { + "type": "integer", + "description": "0-basierter Spaltenindex des ersten Tages (Spalte J = 9)" + }, + "excel_day_end_col": { + "type": "integer", + "description": "0-basierter Spaltenindex des letzten Tages (Spalte AN = 39)" + }, + "month_mapping": { + "type": "object", + "description": "Mapping der deutschen Monatsabkürzungen auf zweistellige Monatsnummern", + "additionalProperties": { + "type": "string", + "pattern": "^(0[1-9]|1[0-2])$" + } + }, + "valid_status_codes": { + "type": "array", + "items": { "type": "string" }, + "description": "Gültige String-Statuscodes in Excel-Zellen (außer numerisch)" + }, + "plan_hours_value": { + "type": "string", + "description": "Wert für das plan.N Dropdown (immer '6')" + }, + "pauschale_value": { + "type": "string", + "description": "Wert für das pauschale.N Feld wenn anwesend" + } + } +} diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config_loader.py b/src/config_loader.py new file mode 100644 index 0000000..42c2de6 --- /dev/null +++ b/src/config_loader.py @@ -0,0 +1,73 @@ +"""Loads and validates config.json for the Attendance PDF Generator.""" + +import json +from pathlib import Path +from typing import Any, Dict + +from .exceptions import ConfigurationError + +_REQUIRED_KEYS = [ + "antragsnummer", + "ort", + "template_pdf", + "output_dir", + "excel_year_pattern", + "excel_name_header", + "excel_name_column", + "excel_seq_column", + "excel_day_start_col", + "excel_day_end_col", + "month_mapping", + "valid_status_codes", + "plan_hours_value", + "pauschale_value", +] + +# Singleton cache so the file is only read once per process +_config_cache: Dict[str, Any] | None = None +_config_path_cache: Path | None = None + + +def load_config(config_path: Path | None = None) -> Dict[str, Any]: + """Load config.json from *config_path* (default: project root config.json). + + Validates that all required keys are present and returns the config dict. + Raises :class:`ConfigurationError` on any problem. + """ + global _config_cache, _config_path_cache + + if config_path is None: + # Locate config.json relative to this file: two levels up = project root + config_path = Path(__file__).parent.parent / "config.json" + + config_path = Path(config_path).resolve() + + # Return cached result if the same file was already loaded + if _config_cache is not None and _config_path_cache == config_path: + return _config_cache + + if not config_path.is_file(): + raise ConfigurationError(f"config.json nicht gefunden: {config_path}") + + try: + raw = config_path.read_text(encoding="utf-8") + data: Dict[str, Any] = json.loads(raw) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigurationError(f"config.json konnte nicht gelesen werden: {exc}") from exc + + missing = [k for k in _REQUIRED_KEYS if k not in data] + if missing: + raise ConfigurationError( + f"config.json fehlen Pflichtfelder: {', '.join(missing)}" + ) + + _config_cache = data + _config_path_cache = config_path + return data + + +def reset_config_cache() -> None: + """Clear the in-process config cache (used in tests).""" + global _config_cache, _config_path_cache + _config_cache = None + _config_path_cache = None diff --git a/src/excel_reader.py b/src/excel_reader.py new file mode 100644 index 0000000..a84fb52 --- /dev/null +++ b/src/excel_reader.py @@ -0,0 +1,280 @@ +"""Reads attendance data from Excel (.xlsx) files. + +Public API +---------- +extract_year_from_filename(file_path, config) -> int +get_sheet_names(file_path) -> list[str] +extract_persons(file_path, sheet_name, config) -> list[Person] +extract_attendance(file_path, sheet_name, person, year, config) -> MonthlyAttendance +""" + +import re +from datetime import date +from pathlib import Path +from typing import Any, Dict, List, Tuple + +import openpyxl + +from .config_loader import load_config +from .exceptions import ( + ExcelDataError, + ExcelFileError, + ExcelSheetError, + YearExtractionError, +) +from .models import AttendanceDate, MonthlyAttendance, Person + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _open_workbook(file_path: Path): + """Open an xlsx workbook in read-only, data-only mode.""" + try: + return openpyxl.load_workbook(str(file_path), read_only=True, data_only=True) + except Exception as exc: + raise ExcelFileError(f"Excel-Datei konnte nicht geöffnet werden: {exc}") from exc + + +def _sheet_data(workbook, sheet_name: str) -> List[List[Any]]: + """Return all rows of *sheet_name* as a list-of-lists of raw cell values.""" + if sheet_name not in workbook.sheetnames: + raise ExcelSheetError( + f"Sheet '{sheet_name}' nicht in der Arbeitsmappe gefunden. " + f"Verfügbar: {workbook.sheetnames}" + ) + ws = workbook[sheet_name] + return [[cell.value for cell in row] for row in ws.iter_rows()] + + +def _find_header_row( + data: List[List[Any]], name_col: int, name_header: str +) -> int: + """Return the 0-based row index of the header row. + + The header row is identified by its value in *name_col* containing + *name_header* as a substring. + """ + for idx, row in enumerate(data): + if len(row) > name_col and row[name_col] is not None: + if name_header in str(row[name_col]): + return idx + raise ExcelDataError( + f"Headerzeile mit '{name_header}' in Spaltenindex {name_col} nicht gefunden." + ) + + +def _parse_cell_value( + cell_value: Any, valid_status_codes: List[str] +) -> Tuple[str, float | None] | None: + """Interpret one Excel day cell. + + Returns (status, hours) tuple or None if the cell should be skipped. + - Numeric value → ("X", hours_as_float) + - Known letter code → (code, None) + - Empty/unknown → None + """ + if cell_value is None: + return None + + # Numeric: present with hours + if isinstance(cell_value, (int, float)): + hours = float(cell_value) + if hours <= 0: + return None + return ("X", hours) + + # String codes (K, KK, F, E, P, U – and optionally "X" as explicit present) + if isinstance(cell_value, str): + stripped = cell_value.strip().upper() + if not stripped: + return None + if stripped == "X": + # Explicit "X" without hours – treat as present with unknown hours + return ("X", None) + if stripped in [c.upper() for c in valid_status_codes]: + # Normalise to the configured casing (config stores uppercase already) + matched = next( + c for c in valid_status_codes if c.upper() == stripped + ) + return (matched, None) + # Unknown string – skip silently + return None + + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def extract_year_from_filename( + file_path: str | Path, config: Dict[str, Any] | None = None +) -> int: + """Extract the 4-digit year from *file_path* using the regex in config. + + Raises :class:`YearExtractionError` when no year can be found. + """ + if config is None: + config = load_config() + pattern = config["excel_year_pattern"] + filename = Path(file_path).name + match = re.search(pattern, filename) + if not match: + raise YearExtractionError( + f"Kein Jahr in Dateiname '{filename}' gefunden (Muster: '{pattern}')." + ) + year = int(match.group(1)) + if not (1900 <= year <= 2099): + raise YearExtractionError(f"Extrahiertes Jahr {year} liegt außerhalb 1900–2099.") + return year + + +def get_sheet_names(file_path: str | Path) -> List[str]: + """Return all sheet names in the workbook.""" + wb = _open_workbook(Path(file_path)) + names = wb.sheetnames + wb.close() + return names + + +def extract_persons( + file_path: str | Path, + sheet_name: str, + config: Dict[str, Any] | None = None, +) -> List[Person]: + """Extract all participants (seq_num, name) from *sheet_name*. + + Persons are rows after the header row where column A (seq_column) holds + an integer and column B (name_column) holds a non-empty string. + """ + if config is None: + config = load_config() + + name_col: int = config["excel_name_column"] + seq_col: int = config["excel_seq_column"] + name_header: str = config["excel_name_header"] + + wb = _open_workbook(Path(file_path)) + try: + data = _sheet_data(wb, sheet_name) + finally: + wb.close() + + header_idx = _find_header_row(data, name_col, name_header) + + persons: List[Person] = [] + for row in data[header_idx + 1 :]: + if len(row) <= max(seq_col, name_col): + continue + seq_val = row[seq_col] + name_val = row[name_col] + if seq_val is None or name_val is None: + continue + name_str = str(name_val).strip() + if not name_str: + continue + try: + seq_num = int(seq_val) + except (ValueError, TypeError): + continue + persons.append(Person(seq_num=seq_num, name=name_str)) + + return persons + + +def extract_attendance( + file_path: str | Path, + sheet_name: str, + person: Person, + year: int, + config: Dict[str, Any] | None = None, +) -> MonthlyAttendance: + """Extract all attendance entries for *person* on *sheet_name*. + + Returns a :class:`MonthlyAttendance` instance. The entries list contains + only days where a valid (non-empty) cell value is present. + Rows exceeding 23 entries are silently truncated (PDF limit). + """ + if config is None: + config = load_config() + + name_col: int = config["excel_name_column"] + seq_col: int = config["excel_seq_column"] + name_header: str = config["excel_name_header"] + day_start: int = config["excel_day_start_col"] + day_end: int = config["excel_day_end_col"] + month_mapping: Dict[str, str] = config["month_mapping"] + valid_codes: List[str] = config["valid_status_codes"] + + if sheet_name not in month_mapping: + raise ExcelSheetError( + f"Sheet-Name '{sheet_name}' nicht in month_mapping. " + f"Bekannte Namen: {list(month_mapping.keys())}" + ) + month_num = int(month_mapping[sheet_name]) + + wb = _open_workbook(Path(file_path)) + try: + data = _sheet_data(wb, sheet_name) + finally: + wb.close() + + header_idx = _find_header_row(data, name_col, name_header) + + # Locate the person's row by matching seq_num (primary) or name (fallback) + person_row: List[Any] | None = None + for row in data[header_idx + 1 :]: + if len(row) <= max(seq_col, name_col): + continue + try: + row_seq = int(row[seq_col]) + except (TypeError, ValueError): + continue + if row_seq == person.seq_num: + person_row = row + break + + if person_row is None: + raise ExcelDataError( + f"Person '{person.name}' (Nr. {person.seq_num}) nicht in Sheet '{sheet_name}' gefunden." + ) + + entries: List[AttendanceDate] = [] + max_days = day_end - day_start + 1 # typically 31 + + for day_index in range(max_days): + col_index = day_start + day_index + if col_index >= len(person_row): + break + + cell_value = person_row[col_index] + parsed = _parse_cell_value(cell_value, valid_codes) + if parsed is None: + continue + + status, hours = parsed + day_num = day_index + 1 + + try: + entry_date = date(year, month_num, day_num) + except ValueError: + # Day does not exist in this month (e.g. Feb 30) + continue + + entries.append( + AttendanceDate(date=entry_date, status=status, hours=hours) + ) + + if len(entries) == 23: + # PDF supports at most 23 rows + break + + return MonthlyAttendance( + person=person, + year=year, + month=month_num, + month_name=sheet_name, + entries=entries, + ) diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..8bd1b65 --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,33 @@ +"""Custom exception hierarchy for the Attendance PDF Generator.""" + + +class AttendanceAppError(Exception): + """Base exception for all application errors.""" + + +class ConfigurationError(AttendanceAppError): + """Raised when config.json is missing, incomplete, or invalid.""" + + +class ExcelFileError(AttendanceAppError): + """Raised for general Excel file access or format problems.""" + + +class ExcelSheetError(AttendanceAppError): + """Raised when a required sheet is not found in the workbook.""" + + +class ExcelDataError(AttendanceAppError): + """Raised when Excel data contains unexpected or invalid values.""" + + +class YearExtractionError(AttendanceAppError): + """Raised when the year cannot be extracted from the filename.""" + + +class PdfTemplateError(AttendanceAppError): + """Raised when the PDF template is missing or unreadable.""" + + +class ValidationError(AttendanceAppError): + """Raised when pre-generation validation fails.""" diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..eda4209 --- /dev/null +++ b/src/models.py @@ -0,0 +1,72 @@ +"""Data models for the Attendance PDF Generator.""" + +from dataclasses import dataclass, field +from datetime import date +from typing import List, Optional + + +@dataclass +class Person: + """Represents a participant read from column A and B of the Excel sheet.""" + + seq_num: int + name: str # Format: "Nachname, Vorname" + + def __post_init__(self) -> None: + if not self.name or not self.name.strip(): + raise ValueError("Person name must not be empty.") + + @property + def filename_safe_name(self) -> str: + """Returns a filename-safe representation: '(n) Nachname, Vorname'.""" + safe = self.name.replace("/", "-").replace("\\", "-").replace(":", "-") + return f"({self.seq_num}) {safe}" + + +@dataclass +class AttendanceDate: + """Represents one day's attendance entry for a person.""" + + date: date + status: str # "X", "K", "KK", "E", "F", "P", "U" + hours: Optional[float] = None # Only set when status == "X" + + def date_german(self) -> str: + """Returns the date in DD.MM.YYYY format.""" + return self.date.strftime("%d.%m.%Y") + + @property + def is_present(self) -> bool: + return self.status == "X" + + def hours_german(self) -> str: + """Returns hours as a German-formatted string (comma decimal separator).""" + if self.hours is None: + return "" + if self.hours == int(self.hours): + return str(int(self.hours)) + return str(self.hours).replace(".", ",") + + +@dataclass +class MonthlyAttendance: + """Aggregates all attendance entries for one person in one month.""" + + person: Person + year: int + month: int + month_name: str + entries: List[AttendanceDate] = field(default_factory=list) + + @property + def monat_jahr(self) -> str: + """Returns 'MM/YYYY' as expected by the PDF field monat_jahr.""" + return f"{self.month:02d}/{self.year}" + + @property + def present_count(self) -> int: + return sum(1 for e in self.entries if e.is_present) + + @property + def total_entries(self) -> int: + return len(self.entries) diff --git a/src/pdf_filler.py b/src/pdf_filler.py new file mode 100644 index 0000000..1633b59 --- /dev/null +++ b/src/pdf_filler.py @@ -0,0 +1,115 @@ +"""Fills the PDF template with attendance data. + +Public API +---------- +build_field_dict(attendance, config, creation_date) -> dict[str, str] +fill_pdf(attendance, template_path, output_path, config, creation_date) +""" + +from datetime import date +from pathlib import Path +from typing import Any, Dict, Optional + +from pypdf import PdfReader, PdfWriter + +from .config_loader import load_config +from .exceptions import PdfTemplateError +from .models import MonthlyAttendance + + +def build_field_dict( + attendance: MonthlyAttendance, + config: Dict[str, Any] | None = None, + creation_date: Optional[date] = None, +) -> Dict[str, str]: + """Build a flat ``{field_name: value}`` dict for the PDF AcroForm. + + Header fields: + - ``person.name`` + - ``monat_jahr`` + - ``antragsnummer`` + - ``ort`` + - ``erstellungsdatum`` + + Per-row fields (index 0–22, max 23 rows): + - ``datum.N`` + - ``anwesend.N`` + - ``ist.N`` + - ``plan.N`` + - ``pauschale.N`` + """ + if config is None: + config = load_config() + if creation_date is None: + creation_date = date.today() + + plan_value: str = config["plan_hours_value"] + pauschale_value: str = config["pauschale_value"] + + fields: Dict[str, str] = { + "person.name": attendance.person.name, + "monat_jahr": attendance.monat_jahr, + "antragsnummer": config["antragsnummer"], + "ort": config["ort"], + "erstellungsdatum": creation_date.strftime("%d.%m.%Y"), + } + + for idx, entry in enumerate(attendance.entries): + fields[f"datum.{idx}"] = entry.date_german() + fields[f"anwesend.{idx}"] = entry.status + + if entry.is_present: + fields[f"ist.{idx}"] = entry.hours_german() + fields[f"plan.{idx}"] = plan_value + fields[f"pauschale.{idx}"] = pauschale_value + else: + fields[f"ist.{idx}"] = "" + fields[f"plan.{idx}"] = "" + fields[f"pauschale.{idx}"] = "" + + return fields + + +def fill_pdf( + attendance: MonthlyAttendance, + template_path: str | Path, + output_path: str | Path, + config: Dict[str, Any] | None = None, + creation_date: Optional[date] = None, +) -> None: + """Write a filled PDF for *attendance* to *output_path*. + + Uses pypdf with ``auto_regenerate=False`` to bypass PDF JavaScript and + write field values directly into the PDF stream. + + Raises :class:`PdfTemplateError` when the template is unreadable. + """ + if config is None: + config = load_config() + + template_path = Path(template_path).resolve() + output_path = Path(output_path).resolve() + + if not template_path.is_file(): + raise PdfTemplateError( + f"PDF-Template nicht gefunden: {template_path}" + ) + + fields = build_field_dict(attendance, config, creation_date) + + try: + reader = PdfReader(str(template_path)) + except Exception as exc: + raise PdfTemplateError( + f"PDF-Template konnte nicht gelesen werden: {exc}" + ) from exc + + writer = PdfWriter() + writer.clone_document_from_reader(reader) + + for page in writer.pages: + writer.update_page_form_field_values(page, fields, auto_regenerate=False) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("wb") as fh: + writer.write(fh) diff --git a/src/validators.py b/src/validators.py new file mode 100644 index 0000000..7843e31 --- /dev/null +++ b/src/validators.py @@ -0,0 +1,202 @@ +"""Pre-generation validation and console preview for the Attendance PDF Generator. + +Public API +---------- +validate(file_path, sheet_name, config_path) -> list[str] + Returns a list of error messages (empty = all checks passed). + +show_preview_and_confirm(file_path, sheet_name, year, persons, config) -> bool + Prints the structured preview and returns True only when the user types 'j'. +""" + +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .config_loader import load_config +from .exceptions import ConfigurationError + + +def validate( + file_path: str | Path, + sheet_name: str, + config_path: str | Path | None = None, +) -> List[str]: + """Run all pre-generation checks. + + Returns a list of German error messages. An empty list means all checks + passed. This function never raises exceptions for business-rule failures — + it only collects them. + """ + errors: List[str] = [] + file_path = Path(file_path) + + # --- Check 1: config.json present and complete --------------------------- + try: + config = load_config(config_path) + except ConfigurationError as exc: + errors.append(f"Konfigurationsfehler: {exc}") + # Cannot continue without config + return errors + + # --- Check 2: Excel file exists ------------------------------------------ + if not file_path.exists(): + errors.append(f"Excel-Datei nicht gefunden: {file_path}") + elif not file_path.suffix.lower() in (".xlsx", ".xlsm"): + errors.append( + f"Datei '{file_path.name}' ist keine Excel-Datei (.xlsx/.xlsm)." + ) + else: + # --- Check 3: Year extractable from filename ------------------------- + pattern = config["excel_year_pattern"] + match = re.search(pattern, file_path.name) + if not match: + errors.append( + f"Kein Jahr im Dateinamen '{file_path.name}' gefunden " + f"(Muster: '{pattern}')." + ) + else: + year = int(match.group(1)) + if not (1900 <= year <= 2099): + errors.append( + f"Extrahiertes Jahr {year} liegt außerhalb 1900–2099." + ) + + # --- Check 4–6: Sheet and column structure --------------------------- + try: + import openpyxl + + wb = openpyxl.load_workbook( + str(file_path), read_only=True, data_only=True + ) + sheet_names = wb.sheetnames + + # Check 4: sheet name valid + if sheet_name not in config["month_mapping"]: + errors.append( + f"Sheet-Name '{sheet_name}' nicht in month_mapping der config.json." + ) + if sheet_name not in sheet_names: + errors.append( + f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. " + f"Verfügbar: {sheet_names}" + ) + else: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + + name_col: int = config["excel_name_column"] + seq_col: int = config["excel_seq_column"] + name_header: str = config["excel_name_header"] + day_start: int = config["excel_day_start_col"] + + # Check 5: Column A (seq) present + any_seq = any( + len(r) > seq_col and r[seq_col] is not None for r in rows + ) + if not any_seq: + errors.append("Spalte A (lfd. Nummer) enthält keine Daten.") + + # Check 5b: Column B (name) present with header + header_found = any( + len(r) > name_col + and r[name_col] is not None + and name_header in str(r[name_col]) + for r in rows + ) + if not header_found: + errors.append( + f"Headerzeile mit '{name_header}' in Spaltenindex {name_col} " + "nicht gefunden." + ) + + # Check 6: Day columns present + any_days = any( + len(r) > day_start for r in rows + ) + if not any_days: + errors.append( + f"Keine Tagesspalten ab Spaltenindex {day_start} gefunden." + ) + + # Check 7: Attendance values valid + valid_codes = [c.upper() for c in config["valid_status_codes"]] + invalid_vals: List[str] = [] + for r in rows: + day_end: int = config["excel_day_end_col"] + for col_idx in range(day_start, min(day_end + 1, len(r))): + val = r[col_idx] + if val is None: + continue + if isinstance(val, (int, float)): + continue # numeric hours – valid + if isinstance(val, str): + stripped = val.strip().upper() + if stripped in valid_codes or stripped == "X": + continue + if stripped: + invalid_vals.append(repr(val)) + if invalid_vals: + unique = list(dict.fromkeys(invalid_vals))[:5] + errors.append( + f"Ungültige Anwesenheitswerte in Tagesspalten: " + f"{', '.join(unique)}" + + (" …" if len(invalid_vals) > 5 else "") + ) + + wb.close() + + except Exception as exc: + errors.append(f"Excel-Datei konnte nicht geöffnet werden: {exc}") + + # --- Check 8: PDF template accessible ------------------------------------ + config_dir = Path(config_path).parent.resolve() if config_path else Path(__file__).parent.parent.resolve() + template_path = (config_dir / config["template_pdf"]).resolve() + + if not template_path.is_file(): + errors.append(f"PDF-Template nicht gefunden: {template_path}") + + return errors + + +def show_preview_and_confirm( + file_path: str | Path, + sheet_name: str, + year: int, + person_count: int, + config: Dict[str, Any], + errors: Optional[List[str]] = None, +) -> bool: + """Print the structured preview to stdout and return True only on 'j'. + + If *errors* is provided and non-empty, each error is listed before the + confirmation prompt and the function still asks the user to confirm. + """ + month_mapping: Dict[str, str] = config["month_mapping"] + month_num = month_mapping.get(sheet_name, "??") + month_display = f"{sheet_name} ({month_num})" + + from datetime import date + + erstelldatum = date.today().strftime("%d.%m.%Y") + + print() + print("=== Vorschau der ermittelten Werte ===") + print(f"Excel-Datei : {Path(file_path).name}") + print(f"Jahr : {year}") + print(f"Monat : {month_display}") + print(f"Antragsnummer : {config['antragsnummer']}") + print(f"Ort : {config['ort']}") + print(f"Erstelldatum : {erstelldatum}") + print(f"Teilnehmer : {person_count}") + print(f"Fehler : {len(errors) if errors else 0}") + + if errors: + print() + print("--- Validierungsfehler ---") + for err in errors: + print(f" [!] {err}") + + print() + answer = input("Möchten Sie die PDF-Generierung starten? [j/N] ").strip().lower() + return answer == "j" diff --git a/template/.gitkeep b/template/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_excel_reader.py b/tests/test_excel_reader.py new file mode 100644 index 0000000..427805e --- /dev/null +++ b/tests/test_excel_reader.py @@ -0,0 +1,278 @@ +"""Tests for src/excel_reader.py — file-independent business logic.""" + +import re +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.exceptions import ExcelDataError, ExcelSheetError, YearExtractionError +from src.excel_reader import ( + _parse_cell_value, + extract_year_from_filename, + extract_persons, + extract_attendance, +) +from src.models import Person + + +# --------------------------------------------------------------------------- +# Minimal config fixture (no real file needed) +# --------------------------------------------------------------------------- + +@pytest.fixture +def cfg(): + return { + "excel_year_pattern": "(19\\d{2}|20\\d{2})", + "excel_name_header": "Name, Vorname", + "excel_name_column": 1, + "excel_seq_column": 0, + "excel_day_start_col": 9, + "excel_day_end_col": 39, + "month_mapping": { + "Jan": "01", "Feb": "02", "Mär": "03", "Mrz": "03", + "Apr": "04", "Mai": "05", "Jun": "06", "Jul": "07", + "Aug": "08", "Sep": "09", "Okt": "10", "Nov": "11", "Dez": "12", + }, + "valid_status_codes": ["K", "KK", "E", "F", "P", "U"], + "plan_hours_value": "6", + "pauschale_value": "8,60", + "antragsnummer": "12345", + "ort": "Teststadt", + "template_pdf": "template/sab.pdf", + "output_dir": "output", + } + + +# --------------------------------------------------------------------------- +# extract_year_from_filename +# --------------------------------------------------------------------------- + +class TestExtractYearFromFilename: + def test_standard_filename(self, cfg): + assert extract_year_from_filename("Anwesenheit_2026.xlsx", cfg) == 2026 + + def test_year_embedded_in_path(self, cfg): + assert extract_year_from_filename("/data/exports/Anwesenheit_2024.xlsx", cfg) == 2024 + + def test_no_year_raises(self, cfg): + with pytest.raises(YearExtractionError): + extract_year_from_filename("Anwesenheit.xlsx", cfg) + + def test_out_of_range_raises(self, cfg): + # Overwrite pattern to force out-of-range year + bad_cfg = {**cfg, "excel_year_pattern": "(1800)"} + with pytest.raises(YearExtractionError): + extract_year_from_filename("data_1800.xlsx", bad_cfg) + + def test_first_year_match_wins(self, cfg): + # Two years in name — first one is used + assert extract_year_from_filename("2023_to_2025_export.xlsx", cfg) == 2023 + + +# --------------------------------------------------------------------------- +# _parse_cell_value +# --------------------------------------------------------------------------- + +class TestParseCellValue: + VALID_CODES = ["K", "KK", "E", "F", "P", "U"] + + def test_numeric_int(self): + result = _parse_cell_value(6, self.VALID_CODES) + assert result == ("X", 6.0) + + def test_numeric_float(self): + result = _parse_cell_value(6.5, self.VALID_CODES) + assert result == ("X", 6.5) + + def test_zero_skipped(self): + assert _parse_cell_value(0, self.VALID_CODES) is None + + def test_negative_skipped(self): + assert _parse_cell_value(-1, self.VALID_CODES) is None + + def test_none_skipped(self): + assert _parse_cell_value(None, self.VALID_CODES) is None + + def test_empty_string_skipped(self): + assert _parse_cell_value("", self.VALID_CODES) is None + assert _parse_cell_value(" ", self.VALID_CODES) is None + + def test_status_codes(self): + for code in self.VALID_CODES: + result = _parse_cell_value(code, self.VALID_CODES) + assert result is not None + assert result[0] == code + assert result[1] is None + + def test_lowercase_status_code(self): + result = _parse_cell_value("k", self.VALID_CODES) + assert result is not None + assert result[0] == "K" + + def test_explicit_x_no_hours(self): + result = _parse_cell_value("X", self.VALID_CODES) + assert result == ("X", None) + + def test_unknown_string_skipped(self): + assert _parse_cell_value("UNBEKANNT", self.VALID_CODES) is None + + +# --------------------------------------------------------------------------- +# extract_persons (mocked workbook) +# --------------------------------------------------------------------------- + +def _make_row(*values): + """Create a list of values as a mock row.""" + return list(values) + + +def _sheet_data_persons(): + """Simulated sheet data: header row + 3 persons.""" + return [ + [None, "Einrichtung XY", None], # row 0 – ignored + [None, "Name, Vorname", None], # row 1 – header + [1, "Mustermann, Max", None], # row 2 – person 1 + [2, "Beispiel, Anna", None], # row 3 – person 2 + [3, "Test, Peter", None], # row 4 – person 3 + [None, None, None], # row 5 – empty row + ] + + +class TestExtractPersons: + def test_returns_correct_persons(self, cfg): + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=_sheet_data_persons()): + mock_wb = MagicMock() + mock_wb.sheetnames = ["Feb"] + mock_open.return_value = mock_wb + + persons = extract_persons("dummy.xlsx", "Feb", cfg) + + assert len(persons) == 3 + assert persons[0].seq_num == 1 + assert persons[1].name == "Beispiel, Anna" + assert persons[2].seq_num == 3 + + def test_empty_rows_ignored(self, cfg): + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=_sheet_data_persons()): + mock_open.return_value = MagicMock(sheetnames=["Feb"]) + persons = extract_persons("dummy.xlsx", "Feb", cfg) + + assert all(p.name for p in persons) + + +# --------------------------------------------------------------------------- +# extract_attendance (mocked workbook) +# --------------------------------------------------------------------------- + +def _sheet_data_attendance(): + """ + Simulated month sheet with one person row. + Columns 0-8: seq, name, and spare cols. + Columns 9-39: days 1-31. + Day 1 (col 9): 6.0 → present 6h + Day 2 (col 10): "K" → sick + Day 3 (col 11): "KK" → child sick + Day 4 (col 12): None → skip + Day 5 (col 13): "F" → unexcused absence + """ + person_row = [None] * 40 + person_row[0] = 1 + person_row[1] = "Mustermann, Max" + person_row[9] = 6.0 + person_row[10] = "K" + person_row[11] = "KK" + person_row[12] = None + person_row[13] = "F" + + return [ + [None, "Name, Vorname"] + [None] * 38, # header row + person_row, + ] + + +class TestExtractAttendance: + def test_present_day(self, cfg): + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()): + mock_open.return_value = MagicMock(sheetnames=["Feb"]) + person = Person(seq_num=1, name="Mustermann, Max") + attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg) + + entry = attendance.entries[0] + assert entry.status == "X" + assert entry.hours == 6.0 + assert entry.date == date(2026, 2, 1) + + def test_sick_day(self, cfg): + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()): + mock_open.return_value = MagicMock(sheetnames=["Feb"]) + person = Person(seq_num=1, name="Mustermann, Max") + attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg) + + entry = attendance.entries[1] + assert entry.status == "K" + assert entry.hours is None + + def test_empty_day_skipped(self, cfg): + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()): + mock_open.return_value = MagicMock(sheetnames=["Feb"]) + person = Person(seq_num=1, name="Mustermann, Max") + attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg) + + # Day 4 (None) is skipped, so entry index 2 is Day 3 (KK) and index 3 is Day 5 (F) + assert len(attendance.entries) == 4 + assert attendance.entries[2].status == "KK" + assert attendance.entries[3].status == "F" + + def test_max_23_entries(self, cfg): + """More than 23 valid entries must be truncated to 23.""" + person_row = [None] * 40 + person_row[0] = 1 + person_row[1] = "Viel, Anwesend" + for col in range(9, 40): # 31 days all present + person_row[col] = 6.0 + + sheet_data = [ + [None, "Name, Vorname"] + [None] * 38, + person_row, + ] + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=sheet_data): + mock_open.return_value = MagicMock(sheetnames=["Jan"]) + person = Person(seq_num=1, name="Viel, Anwesend") + attendance = extract_attendance("dummy.xlsx", "Jan", person, 2026, cfg) + + assert len(attendance.entries) == 23 + + def test_invalid_month_raises(self, cfg): + with pytest.raises(ExcelSheetError): + extract_attendance("dummy.xlsx", "UNGÜLTIG", Person(1, "X, Y"), 2026, cfg) + + def test_february_day_29_invalid_year(self, cfg): + """Day 29 in Feb of a non-leap year must be skipped.""" + person_row = [None] * 40 + person_row[0] = 1 + person_row[1] = "Schaltjahr, Test" + person_row[9 + 27] = 6.0 # day 28 = col 36 → valid + person_row[9 + 28] = 6.0 # day 29 = col 37 → invalid (2025 not leap year) + person_row[9 + 29] = 6.0 # day 30 = col 38 → invalid + + sheet_data = [ + [None, "Name, Vorname"] + [None] * 38, + person_row, + ] + with patch("src.excel_reader._open_workbook") as mock_open, \ + patch("src.excel_reader._sheet_data", return_value=sheet_data): + mock_open.return_value = MagicMock(sheetnames=["Feb"]) + person = Person(seq_num=1, name="Schaltjahr, Test") + attendance = extract_attendance("dummy.xlsx", "Feb", person, 2025, cfg) + + # Only day 28 is valid in Feb 2025 + assert len(attendance.entries) == 1 + assert attendance.entries[0].date == date(2025, 2, 28) diff --git a/tests/test_pdf_filler.py b/tests/test_pdf_filler.py new file mode 100644 index 0000000..f5b5ed6 --- /dev/null +++ b/tests/test_pdf_filler.py @@ -0,0 +1,172 @@ +"""Tests for src/pdf_filler.py — field dict building and pauschale/plan logic.""" + +from datetime import date + +import pytest + +from src.models import AttendanceDate, MonthlyAttendance, Person +from src.pdf_filler import build_field_dict + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def cfg(): + return { + "antragsnummer": "SAB-12345", + "ort": "Leipzig", + "template_pdf": "template/sab.pdf", + "output_dir": "output", + "plan_hours_value": "6", + "pauschale_value": "8,60", + "excel_year_pattern": "(20\\d{2})", + "excel_name_header": "Name, Vorname", + "excel_name_column": 1, + "excel_seq_column": 0, + "excel_day_start_col": 9, + "excel_day_end_col": 39, + "month_mapping": {"Feb": "02"}, + "valid_status_codes": ["K", "KK", "E", "F", "P", "U"], + } + + +def _make_attendance(*entries) -> MonthlyAttendance: + person = Person(seq_num=1, name="Teilnehmer, Test") + return MonthlyAttendance( + person=person, + year=2026, + month=2, + month_name="Feb", + entries=list(entries), + ) + + +def _present(day: int, hours: float) -> AttendanceDate: + return AttendanceDate(date=date(2026, 2, day), status="X", hours=hours) + + +def _absent(day: int, status: str) -> AttendanceDate: + return AttendanceDate(date=date(2026, 2, day), status=status, hours=None) + + +# --------------------------------------------------------------------------- +# Header fields +# --------------------------------------------------------------------------- + +class TestHeaderFields: + def test_person_name(self, cfg): + fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1)) + assert fields["person.name"] == "Teilnehmer, Test" + + def test_monat_jahr(self, cfg): + fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1)) + assert fields["monat_jahr"] == "02/2026" + + def test_antragsnummer(self, cfg): + fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1)) + assert fields["antragsnummer"] == "SAB-12345" + + def test_ort(self, cfg): + fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1)) + assert fields["ort"] == "Leipzig" + + def test_erstellungsdatum(self, cfg): + fields = build_field_dict(_make_attendance(), cfg, date(2026, 3, 27)) + assert fields["erstellungsdatum"] == "27.03.2026" + + +# --------------------------------------------------------------------------- +# Present day (status X) +# --------------------------------------------------------------------------- + +class TestPresentDay: + def test_datum_field(self, cfg): + fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1)) + assert fields["datum.0"] == "03.02.2026" + + def test_anwesend_field(self, cfg): + fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1)) + assert fields["anwesend.0"] == "X" + + def test_ist_field_whole_hours(self, cfg): + fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1)) + assert fields["ist.0"] == "6" + + def test_ist_field_decimal_hours(self, cfg): + fields = build_field_dict(_make_attendance(_present(3, 6.5)), cfg, date(2026, 2, 1)) + assert fields["ist.0"] == "6,5" + + def test_plan_always_6(self, cfg): + fields = build_field_dict(_make_attendance(_present(3, 3.0)), cfg, date(2026, 2, 1)) + assert fields["plan.0"] == "6" + + def test_pauschale_set(self, cfg): + fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1)) + assert fields["pauschale.0"] == "8,60" + + +# --------------------------------------------------------------------------- +# Absent days (K, KK, E, F, P, U) +# --------------------------------------------------------------------------- + +class TestAbsentDay: + @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) + def test_anwesend_field(self, cfg, status): + fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) + assert fields["anwesend.0"] == status + + @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) + def test_ist_empty(self, cfg, status): + fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) + assert fields["ist.0"] == "" + + @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) + def test_plan_empty(self, cfg, status): + fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) + assert fields["plan.0"] == "" + + @pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"]) + def test_pauschale_empty(self, cfg, status): + fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1)) + assert fields["pauschale.0"] == "" + + +# --------------------------------------------------------------------------- +# Multi-row indexing +# --------------------------------------------------------------------------- + +class TestMultiRow: + def test_second_entry_indexed_as_1(self, cfg): + entries = [_present(1, 6.0), _absent(2, "K")] + fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1)) + assert fields["datum.1"] == "02.02.2026" + assert fields["anwesend.1"] == "K" + + def test_23_entries_produce_index_0_to_22(self, cfg): + entries = [_present(i, 6.0) for i in range(1, 24)] # 23 entries max + fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1)) + # Index 22 should exist + assert "datum.22" in fields + assert "anwesend.22" in fields + # Index 23 must NOT exist + assert "datum.23" not in fields + + +# --------------------------------------------------------------------------- +# Hours formatting +# --------------------------------------------------------------------------- + +class TestHoursFormatting: + def test_whole_number_no_decimal(self): + entry = AttendanceDate(date=date(2026, 2, 1), status="X", hours=6.0) + assert entry.hours_german() == "6" + + def test_decimal_comma_separator(self): + entry = AttendanceDate(date=date(2026, 2, 1), status="X", hours=6.5) + assert entry.hours_german() == "6,5" + + def test_none_returns_empty(self): + entry = AttendanceDate(date=date(2026, 2, 1), status="K", hours=None) + assert entry.hours_german() == "" diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..5505f73 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,155 @@ +"""Tests for src/validators.py — pre-generation validation logic.""" + +import json +import re +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.validators import validate + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_config(tmp_path: Path, overrides: dict = None) -> Path: + """Write a valid config.json into tmp_path, optionally applying overrides.""" + cfg = { + "antragsnummer": "12345", + "ort": "Teststadt", + "template_pdf": "template/sab.pdf", + "output_dir": "output", + "excel_year_pattern": "(19\\d{2}|20\\d{2})", + "excel_name_header": "Name, Vorname", + "excel_name_column": 1, + "excel_seq_column": 0, + "excel_day_start_col": 9, + "excel_day_end_col": 39, + "month_mapping": {"Feb": "02", "Jan": "01"}, + "valid_status_codes": ["K", "KK", "E", "F", "P", "U"], + "plan_hours_value": "6", + "pauschale_value": "8,60", + } + if overrides: + cfg.update(overrides) + cfg_path = tmp_path / "config.json" + cfg_path.write_text(json.dumps(cfg), encoding="utf-8") + return cfg_path + + +def _write_dummy_xlsx(tmp_path: Path, name: str = "Anwesenheit_2026.xlsx") -> Path: + """Create a minimal valid xlsx file using openpyxl.""" + import openpyxl + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Feb" + # Header row + ws.cell(row=1, column=2, value="Name, Vorname") + # Day headers (columns J–AN = col 10–40 in 1-based) + for col in range(10, 41): + ws.cell(row=1, column=col, value=col - 9) # day number as header + # Person row + ws.cell(row=2, column=1, value=1) + ws.cell(row=2, column=2, value="Mustermann, Max") + ws.cell(row=2, column=10, value=6.0) # day 1 → present 6h + xlsx_path = tmp_path / name + wb.save(str(xlsx_path)) + return xlsx_path + + +def _write_dummy_pdf(tmp_path: Path) -> Path: + """Create a 1-byte placeholder file that passes existence checks.""" + pdf_path = tmp_path / "template" / "sab.pdf" + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_path.write_bytes(b"%PDF-1.4 placeholder") + return pdf_path + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestValidate: + def test_all_checks_pass(self, tmp_path): + cfg_path = _write_config(tmp_path) + xlsx_path = _write_dummy_xlsx(tmp_path) + _write_dummy_pdf(tmp_path) + + # Reset config cache so the test config is used + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(xlsx_path, "Feb", cfg_path) + assert errors == [] + + def test_excel_file_missing(self, tmp_path): + cfg_path = _write_config(tmp_path) + _write_dummy_pdf(tmp_path) + + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(tmp_path / "nonexistent.xlsx", "Feb", cfg_path) + assert any("nicht gefunden" in e for e in errors) + + def test_year_not_in_filename(self, tmp_path): + cfg_path = _write_config(tmp_path) + xlsx_path = _write_dummy_xlsx(tmp_path, name="Anwesenheit_kein_jahr.xlsx") + _write_dummy_pdf(tmp_path) + + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(xlsx_path, "Feb", cfg_path) + assert any("Jahr" in e for e in errors) + + def test_unknown_sheet_name(self, tmp_path): + cfg_path = _write_config(tmp_path) + xlsx_path = _write_dummy_xlsx(tmp_path) + _write_dummy_pdf(tmp_path) + + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(xlsx_path, "Xyz", cfg_path) + assert any("month_mapping" in e or "Sheet" in e for e in errors) + + def test_pdf_template_missing(self, tmp_path): + # No _write_dummy_pdf call → template missing + cfg_path = _write_config(tmp_path) + xlsx_path = _write_dummy_xlsx(tmp_path) + + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(xlsx_path, "Feb", cfg_path) + assert any("Template" in e or "template" in e.lower() for e in errors) + + def test_config_missing_key(self, tmp_path): + cfg = { + "antragsnummer": "123", + # omit 'ort' and other required keys + } + cfg_path = tmp_path / "config.json" + cfg_path.write_text(json.dumps(cfg), encoding="utf-8") + + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(tmp_path / "file.xlsx", "Feb", cfg_path) + assert any("Konfigurationsfehler" in e or "fehlen" in e.lower() for e in errors) + + def test_non_xlsx_extension_flagged(self, tmp_path): + cfg_path = _write_config(tmp_path) + _write_dummy_pdf(tmp_path) + + csv_path = tmp_path / "Anwesenheit_2026.csv" + csv_path.write_text("dummy") + + from src.config_loader import reset_config_cache + reset_config_cache() + + errors = validate(csv_path, "Feb", cfg_path) + assert any(".xlsx" in e or "Excel" in e for e in errors)