refactor notifications to MVC: extract templates, context, and renderer

- Move all email layout to Jinja2 templates (4 files: admin_new,
  admin_update, parent_confirmation html+txt)
- Extract pure helper/context-builder functions to context.py
  (format_types, calculate_age, format_dob, calculate_monthly_fee,
  format_days, build_admin_new_context, build_admin_update_context,
  build_parent_context)
- Move i18n label strings out of Python into YAML locale files
  (i18n/de.yaml, i18n/en.yaml) — admin-editable without code changes
- Add renderer.py as thin Jinja2 wrapper (render_template)
- Reduce notifier.py to routing + SMTP dispatch only
- Update tests to call context functions directly and render
  templates via renderer instead of calling private builder methods
- Add jinja2 and pyyaml as explicit dependencies in pyproject.toml

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
This commit is contained in:
Claude
2026-02-23 10:05:03 +00:00
parent b2e04fa07b
commit 54d88d1d64
11 changed files with 699 additions and 596 deletions
+231
View File
@@ -0,0 +1,231 @@
"""Pure functions for building email context dicts from registration data."""
from datetime import date, datetime
from pathlib import Path
import yaml
from ..models.registration import RegistrationData
# ---------------------------------------------------------------------------
# Swiss QR-bill payment constants (stable bank details — not in config)
# ---------------------------------------------------------------------------
QR_IBAN = "CH14 0900 0000 4930 8018 8"
QR_PAYEE = "Familienverein Fällanden Spielgruppen"
QR_STREET = "Huebwisstrase 5"
QR_PCODE = "8117"
QR_CITY = "Fällanden"
_I18N_DIR = Path(__file__).parent / "i18n"
# ---------------------------------------------------------------------------
# i18n
# ---------------------------------------------------------------------------
def load_strings(language: str) -> dict:
"""Load the label/string table for *language* (falls back to German)."""
locale_file = _I18N_DIR / f"{language}.yaml"
if not locale_file.exists():
locale_file = _I18N_DIR / "de.yaml"
with locale_file.open(encoding="utf-8") as fh:
return yaml.safe_load(fh)
# ---------------------------------------------------------------------------
# Formatting helpers (pure functions, no side-effects)
# ---------------------------------------------------------------------------
def format_dob(dob_str: str) -> str:
"""Return DD.MM.YYYY from a YYYY-MM-DD string, or the original on error."""
try:
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return dob_str or ""
def calculate_age(dob_str: str) -> str:
"""Return 'X Jahre, Y Monate' from a YYYY-MM-DD string."""
try:
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
today = date.today()
years = today.year - dob.year - (
(today.month, today.day) < (dob.month, dob.day)
)
months = (today.month - dob.month) % 12
return f"{years} Jahre, {months} Monate"
except Exception:
return dob_str
def format_types(types: list[str]) -> str:
"""German label for a list of playgroup type keys (admin emails)."""
has_indoor = "indoor" in types
has_outdoor = "outdoor" in types
if has_indoor and has_outdoor:
return "Innen- und Waldspielgruppe"
if has_indoor:
return "Innenspielgruppe"
if has_outdoor:
return "Waldspielgruppe"
return "Spielgruppe"
def format_types_i18n(types: list[str], strings: dict) -> str:
"""Localised label for playgroup type keys using the supplied string table."""
type_map: dict = strings["types"]
labels = [type_map.get(t, t) for t in types]
return ", ".join(labels) if labels else ""
def format_days(registration: RegistrationData) -> str:
"""German day + type labels for admin emails."""
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
return ", ".join(
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
for d in registration.booking.selected_days
)
def format_days_i18n(registration: RegistrationData, strings: dict) -> str:
"""Localised day + type labels using the supplied string table."""
day_map: dict = strings["days"]
type_map: dict = strings["types"]
return ", ".join(
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
for d in registration.booking.selected_days
)
def calculate_monthly_fee(registration: RegistrationData) -> str:
"""Compute the monthly fee string from the booking selection."""
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
fee = 0
if indoor_days == 1:
fee += 130
elif indoor_days == 2:
fee += 260
elif indoor_days >= 3:
fee += 390
if outdoor_days >= 1:
fee += 250
return f"CHF {fee}.-"
# ---------------------------------------------------------------------------
# Context builders
# ---------------------------------------------------------------------------
def build_admin_new_context(
registration: RegistrationData,
registration_id: str,
version: int,
channel: str,
) -> dict:
"""Build the template context for the admin new-registration email."""
now = datetime.utcnow()
pg = registration.parent_guardian
ec = registration.emergency_contact
ch = registration.child
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
return {
"submitted_date": now.strftime("%d.%m.%Y"),
"submitted_time": now.strftime("%H:%M"),
"channel": channel_de,
"registration_id": registration_id,
"version": version,
"child_name": ch.full_name or "",
"child_dob": format_dob(ch.date_of_birth or ""),
"child_age": calculate_age(ch.date_of_birth or ""),
"child_needs": ch.special_needs or "Keine",
"playgroup_types": format_types(registration.booking.playgroup_types),
"days": format_days(registration),
"monthly_fee": calculate_monthly_fee(registration),
"parent_name": pg.full_name or "",
"parent_street": pg.street_address or "",
"parent_postal_code": pg.postal_code or "",
"parent_city": pg.city or "",
"parent_phone": pg.phone or "",
"parent_email": pg.email or "",
"emergency_name": ec.full_name or "",
"emergency_phone": ec.phone or "",
}
def build_admin_update_context(
registration: RegistrationData,
registration_id: str,
version: int,
change_summary: dict,
) -> dict:
"""Build the template context for the admin registration-update email."""
now = datetime.utcnow()
pg = registration.parent_guardian
changes = [
{"field": field_path, "old": values["old"], "new": values["new"]}
for field_path, values in sorted(change_summary.items())
]
return {
"updated_date": now.strftime("%d.%m.%Y"),
"updated_time": now.strftime("%H:%M"),
"registration_id": registration_id,
"version": version,
"child_name": registration.child.full_name or "",
"parent_email": pg.email or "",
"changes": changes,
"playgroup_types": format_types(registration.booking.playgroup_types),
"days": format_days(registration),
"monthly_fee": calculate_monthly_fee(registration),
"parent_name": pg.full_name or "",
"parent_street": pg.street_address or "",
"parent_postal_code": pg.postal_code or "",
"parent_city": pg.city or "",
"parent_phone": pg.phone or "",
}
def build_parent_context(
registration: RegistrationData,
strings: dict,
has_qr: bool = True,
) -> dict:
"""Build the template context for the parent confirmation email."""
pg = registration.parent_guardian
ec = registration.emergency_contact
ch = registration.child
parent_name = pg.full_name or pg.email or ""
return {
"lang": "de" if strings.get("none") == "Keine" else "en",
"strings": strings,
"greeting": strings["greeting"].format(name=parent_name),
"child_name": ch.full_name or "",
"child_dob": format_dob(ch.date_of_birth or ""),
"child_needs": ch.special_needs or strings["none"],
"playgroup_types": format_types_i18n(registration.booking.playgroup_types, strings),
"days": format_days_i18n(registration, strings),
"monthly_fee": calculate_monthly_fee(registration),
"has_indoor": "indoor" in registration.booking.playgroup_types,
"has_qr": has_qr,
"parent_name": pg.full_name or "",
"parent_address": pg.street_address or "",
"parent_postal_code": pg.postal_code or "",
"parent_city": pg.city or "",
"parent_phone": pg.phone or "",
"parent_email": pg.email or "",
"emergency_name": ec.full_name or "",
"emergency_phone": ec.phone or "",
"iban": QR_IBAN,
"payee": QR_PAYEE,
"payee_street": QR_STREET,
"payee_postal_code": QR_PCODE,
"payee_city": QR_CITY,
}