erster versuch der KI mit Plan

This commit is contained in:
Rob committed 2026-03-27 13:18:07 +01:00
1 parent c697c64f3a
commit 2faa1689dd
20 files changed
+1749

No files matched your search

+60
View File
@@ -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 <name>` (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) | — | — | — | — |
+40
View File
@@ -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
+30
View File
@@ -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"
}
+149
View File
@@ -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()
View File
Whitespace-only changes.
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=7.0.0
+3
View File
@@ -0,0 +1,3 @@
openpyxl>=3.1.2
pypdf>=4.0.0
click>=8.1.0
+85
View File
@@ -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"
}
}
}
View File
Whitespace-only changes.
+73
View File
@@ -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
+280
View File
@@ -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,
)
+33
View File
@@ -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."""
+72
View File
@@ -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)
+115
View File
@@ -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)
+202
View File
@@ -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"
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+278
View File
@@ -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)
+172
View File
@@ -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() == ""
+155
View File
@@ -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)