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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user