from __future__ import annotations import locale import os import re from datetime import date, datetime class DateValidationError(ValueError): pass def setup_system_locale() -> None: try: locale.setlocale(locale.LC_TIME, "") except locale.Error: pass def system_date_pattern() -> str: try: system_pattern = locale.nl_langinfo(locale.D_FMT) except (AttributeError, ValueError): system_pattern = "" year_position = system_pattern.find("%Y") day_position = system_pattern.find("%d") month_position = system_pattern.find("%m") if ( year_position >= 0 and day_position >= 0 and month_position >= 0 and year_position < min(day_position, month_position) ): return "%Y-%m-%d" if day_position >= 0 and month_position >= 0 and day_position < month_position: return "%d.%m.%Y" locale_hint = " ".join( filter( None, ( os.environ.get("LC_TIME"), os.environ.get("LANGUAGE"), os.environ.get("LANG"), ), ) ).lower() day_first_languages = ("de", "at", "ch", "fr", "it", "es", "pt", "nl", "pl", "cs") return "%d.%m.%Y" if locale_hint.startswith(day_first_languages) else "%Y-%m-%d" def date_input_hint() -> str: return "DD.MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM-DD" def parse_date_input(value: str, field_name: str, *, allow_empty: bool = True) -> date | None: text = value.strip() if not text: if allow_empty: return None raise DateValidationError(f"{field_name} ist erforderlich.") patterns = (system_date_pattern(), "%Y-%m-%d", "%d.%m.%Y") for pattern in dict.fromkeys(patterns): expected = r"\d{2}\.\d{2}\.\d{4}" if pattern == "%d.%m.%Y" else r"\d{4}-\d{2}-\d{2}" if not re.fullmatch(expected, text): continue try: return datetime.strptime(text, pattern).date() except ValueError: continue formats = date_input_hint() if formats != "YYYY-MM-DD": formats += " oder YYYY-MM-DD" raise DateValidationError(f"{field_name} muss ein gültiges Datum im Format {formats} sein.") def normalize_date_input(value: str, field_name: str) -> str: parsed = parse_date_input(value, field_name) return parsed.isoformat() if parsed else "" def format_date_for_display(value: str) -> str: text = value.strip() if not text: return "" try: parsed = parse_iso_date(text, "Datum") except DateValidationError: return text return parsed.strftime(system_date_pattern()) if parsed else "" def parse_iso_date(value: str, field_name: str, *, allow_empty: bool = True) -> date | None: text = value.strip() if not text: if allow_empty: return None raise DateValidationError(f"{field_name} ist erforderlich.") if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): raise DateValidationError(f"{field_name} muss das Format JJJJ-MM-TT haben.") try: return date.fromisoformat(text) except ValueError as exc: raise DateValidationError(f"{field_name} ist kein gültiges Kalenderdatum.") from exc def validate_birth_date(value: str, *, today: date | None = None) -> date | None: parsed = parse_iso_date(value, "Geburtsdatum") if parsed is None: return None reference = today or date.today() _validate_birth_date_value(parsed, reference) return parsed def validate_member_dates( *, birth_date: str, accepted_at: str = "", membership_started_at: str = "", today: date | None = None, ) -> None: reference = today or date.today() birth = validate_birth_date(birth_date, today=reference) accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference) started = _validate_not_future(membership_started_at, "Mitglied seit", reference) if birth and accepted and accepted < birth: raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.") if birth and started and started < birth: raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.") if accepted and started and started < accepted: raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.") def calculate_age(birth_date: date, on_date: date | None = None) -> int: reference = on_date or date.today() return ( reference.year - birth_date.year - ((reference.month, reference.day) < (birth_date.month, birth_date.day)) ) def age_label(value: str, *, today: date | None = None) -> str: if not value.strip(): return "Alter: —" try: parsed = parse_date_input(value, "Geburtsdatum") if parsed: _validate_birth_date_value(parsed, today or date.today()) except DateValidationError: return "UNGÜLTIGES DATUM" return f"Alter: {calculate_age(parsed, today)} Jahre" if parsed else "Alter: —" def _validate_not_future(value: str, field_name: str, reference: date) -> date | None: parsed = parse_iso_date(value, field_name) if parsed and parsed > reference: raise DateValidationError(f"{field_name} darf nicht in der Zukunft liegen.") return parsed def _validate_birth_date_value(parsed: date, reference: date) -> None: if parsed > reference: raise DateValidationError("Geburtsdatum darf nicht in der Zukunft liegen.") if calculate_age(parsed, reference) > 120: raise DateValidationError("Geburtsdatum ist unplausibel: Das berechnete Alter liegt über 120.")