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
+2
View File
@@ -13,6 +13,8 @@ dependencies = [
"chainlit>=2.9.6", "chainlit>=2.9.6",
"qrbill>=1.2.0", "qrbill>=1.2.0",
"pillow>=12.1.1", "pillow>=12.1.1",
"jinja2>=3.0.0",
"pyyaml>=6.0.0",
] ]
[project.scripts] [project.scripts]
+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,
}
+57
View File
@@ -0,0 +1,57 @@
subject: "Anmeldebestätigung Spielgruppe Pumuckl"
greeting: "Guten Tag {name}"
intro: >-
Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen.
Hier ist eine Zusammenfassung:
child_section: "Angaben zum Kind"
child_name: "Name"
child_dob: "Geburtsdatum"
child_needs: "Besondere Bedürfnisse"
booking_section: "Spielgruppen-Buchung"
booking_type: "Art"
booking_days: "Tage"
fees_section: "Kosten"
monthly_fee: "Monatlicher Beitrag"
reg_fee: "Anmeldegebühr (einmalig, erstes Jahr)"
reg_fee_amount: "CHF 80.00"
deposit: "Reinigungsdepot Innen (rückerstattbar)"
deposit_amount: "CHF 50.00"
parent_section: "Deine Kontaktdaten"
parent_name: "Name"
parent_address: "Adresse"
parent_phone: "Telefon"
parent_email: "E-Mail"
emergency_section: "Notfallkontakt"
emergency_name: "Name"
emergency_phone: "Telefon"
payment_section: "Zahlungsinformationen"
payment_intro: >-
Bitte überweise die Anmeldegebühr von <strong>CHF&nbsp;80.00</strong> auf folgendes Konto.
Du kannst den QR-Code mit deiner Banking-App scannen:
payment_intro_text: "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:"
iban_label: "IBAN"
payee_label: "Empfänger"
amount_label: "Betrag"
closing: |
Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind!
Herzliche Grüsse
Spielgruppe Pumuckl
none: "Keine"
days:
monday: "Montag"
wednesday: "Mittwoch"
thursday: "Donnerstag"
types:
indoor: "Innenspielgruppe"
outdoor: "Waldspielgruppe"
+57
View File
@@ -0,0 +1,57 @@
subject: "Registration Confirmation Spielgruppe Pumuckl"
greeting: "Dear {name}"
intro: >-
Your registration with Spielgruppe Pumuckl has been received.
Here is a summary:
child_section: "Child Details"
child_name: "Name"
child_dob: "Date of birth"
child_needs: "Special needs"
booking_section: "Playgroup Booking"
booking_type: "Type"
booking_days: "Days"
fees_section: "Fees"
monthly_fee: "Monthly subscription"
reg_fee: "Registration fee (one-time, first year)"
reg_fee_amount: "CHF 80.00"
deposit: "Cleaning deposit indoor (refundable)"
deposit_amount: "CHF 50.00"
parent_section: "Your Contact Details"
parent_name: "Name"
parent_address: "Address"
parent_phone: "Phone"
parent_email: "Email"
emergency_section: "Emergency Contact"
emergency_name: "Name"
emergency_phone: "Phone"
payment_section: "Payment Details"
payment_intro: >-
Please transfer the registration fee of <strong>CHF&nbsp;80.00</strong> to the account below.
You can scan the QR code with your banking app:
payment_intro_text: "Please transfer the registration fee of CHF 80.00 to the following account:"
iban_label: "IBAN"
payee_label: "Payee"
amount_label: "Amount"
closing: |
If you have any questions, we are happy to help. We look forward to welcoming your child!
Kind regards
Spielgruppe Pumuckl
none: "None"
days:
monday: "Monday"
wednesday: "Wednesday"
thursday: "Thursday"
types:
indoor: "Indoor Playgroup"
outdoor: "Forest Playgroup"
+84 -564
View File
@@ -3,7 +3,6 @@
import io import io
import logging import logging
import smtplib import smtplib
from datetime import date, datetime
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -14,136 +13,23 @@ from PIL import Image, ImageDraw
from qrbill import QRBill from qrbill import QRBill
from ..models.registration import RegistrationData from ..models.registration import RegistrationData
from .context import (
QR_CITY,
QR_IBAN,
QR_PAYEE,
QR_PCODE,
QR_STREET,
build_admin_new_context,
build_admin_update_context,
build_parent_context,
format_types,
load_strings,
)
from .renderer import render_template
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Bilingual string tables for parent confirmation emails
# ---------------------------------------------------------------------------
_STRINGS_DE: dict = {
"subject": "Anmeldebestätigung Spielgruppe Pumuckl",
"greeting": "Guten Tag {name}",
"intro": (
"Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen. "
"Hier ist eine Zusammenfassung:"
),
"child_section": "Angaben zum Kind",
"child_name": "Name",
"child_dob": "Geburtsdatum",
"child_needs": "Besondere Bedürfnisse",
"booking_section": "Spielgruppen-Buchung",
"booking_type": "Art",
"booking_days": "Tage",
"fees_section": "Kosten",
"monthly_fee": "Monatlicher Beitrag",
"reg_fee": "Anmeldegebühr (einmalig, erstes Jahr)",
"deposit": "Reinigungsdepot Innen (rückerstattbar)",
"parent_section": "Deine Kontaktdaten",
"parent_name": "Name",
"parent_address": "Adresse",
"parent_phone": "Telefon",
"parent_email": "E-Mail",
"emergency_section": "Notfallkontakt",
"emergency_name": "Name",
"emergency_phone": "Telefon",
"payment_section": "Zahlungsinformationen",
"payment_intro": (
"Bitte überweise die Anmeldegebühr von <strong>CHF&nbsp;80.00</strong> auf folgendes Konto. "
"Du kannst den QR-Code mit deiner Banking-App scannen:"
),
"payment_intro_text": (
"Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:"
),
"iban_label": "IBAN",
"payee_label": "Empfänger",
"amount_label": "Betrag",
"closing": (
"Bei Fragen stehen wir dir gerne zur Verfügung. "
"Wir freuen uns auf dein Kind!\n\n"
"Herzliche Grüsse\n"
"Spielgruppe Pumuckl"
),
"days": {
"monday": "Montag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
},
"types": {
"indoor": "Innenspielgruppe",
"outdoor": "Waldspielgruppe",
},
"none": "Keine",
"deposit_amount": "CHF 50.00",
"reg_fee_amount": "CHF 80.00",
}
_STRINGS_EN: dict = {
"subject": "Registration Confirmation Spielgruppe Pumuckl",
"greeting": "Dear {name}",
"intro": (
"Your registration with Spielgruppe Pumuckl has been received. "
"Here is a summary:"
),
"child_section": "Child Details",
"child_name": "Name",
"child_dob": "Date of birth",
"child_needs": "Special needs",
"booking_section": "Playgroup Booking",
"booking_type": "Type",
"booking_days": "Days",
"fees_section": "Fees",
"monthly_fee": "Monthly subscription",
"reg_fee": "Registration fee (one-time, first year)",
"deposit": "Cleaning deposit indoor (refundable)",
"parent_section": "Your Contact Details",
"parent_name": "Name",
"parent_address": "Address",
"parent_phone": "Phone",
"parent_email": "Email",
"emergency_section": "Emergency Contact",
"emergency_name": "Name",
"emergency_phone": "Phone",
"payment_section": "Payment Details",
"payment_intro": (
"Please transfer the registration fee of <strong>CHF&nbsp;80.00</strong> to the account below. "
"You can scan the QR code with your banking app:"
),
"payment_intro_text": (
"Please transfer the registration fee of CHF 80.00 to the following account:"
),
"iban_label": "IBAN",
"payee_label": "Payee",
"amount_label": "Amount",
"closing": (
"If you have any questions, we are happy to help. "
"We look forward to welcoming your child!\n\n"
"Kind regards\n"
"Spielgruppe Pumuckl"
),
"days": {
"monday": "Monday",
"wednesday": "Wednesday",
"thursday": "Thursday",
},
"types": {
"indoor": "Indoor Playgroup",
"outdoor": "Forest Playgroup",
},
"none": "None",
"deposit_amount": "CHF 50.00",
"reg_fee_amount": "CHF 80.00",
}
# Fixed Swiss QR-bill payment data (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"
class AdminNotifier: class AdminNotifier:
"""Sends formatted admin notification emails. """Sends formatted admin notification emails.
@@ -200,9 +86,10 @@ class AdminNotifier:
subject = ( subject = (
f"Neue Anmeldung: {registration.child.full_name} " f"Neue Anmeldung: {registration.child.full_name} "
f" {self._format_types(types)}" f" {format_types(types)}"
) )
body = self._build_new_body(registration, registration_id, version, channel) ctx = build_admin_new_context(registration, registration_id, version, channel)
body = render_template("admin_new.txt.j2", ctx)
self._send( self._send(
to=to_addresses, to=to_addresses,
@@ -231,7 +118,8 @@ class AdminNotifier:
return return
subject = f"Anmeldung aktualisiert: {registration.child.full_name}" subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
body = self._build_update_body(registration, registration_id, version, change_summary) ctx = build_admin_update_context(registration, registration_id, version, change_summary)
body = render_template("admin_update.txt.j2", ctx)
self._send( self._send(
to=to_addresses, to=to_addresses,
@@ -241,244 +129,6 @@ class AdminNotifier:
reply_to=registration.parent_guardian.email or "", reply_to=registration.parent_guardian.email or "",
) )
# ------------------------------------------------------------------
# Routing helpers
# ------------------------------------------------------------------
def _recipients_for(self, types: list[str]) -> list[str]:
"""Return To addresses based on which playgroup types are booked."""
recipients = []
if "indoor" in types and self._indoor_email:
recipients.append(self._indoor_email)
if "outdoor" in types and self._outdoor_email:
recipients.append(self._outdoor_email)
return recipients
# ------------------------------------------------------------------
# Formatting helpers
# ------------------------------------------------------------------
@staticmethod
def _format_types(types: list[str]) -> str:
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"
@staticmethod
def _calculate_age(dob_str: str) -> str:
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
@staticmethod
def _format_dob(dob_str: str) -> str:
try:
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return dob_str or ""
@staticmethod
def _calculate_monthly_fee(registration: RegistrationData) -> str:
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}.-"
@staticmethod
def _format_days(registration: RegistrationData) -> str:
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
)
@staticmethod
def _format_change_summary(change_summary: dict) -> str:
"""Render field changes as a human-readable list."""
lines = []
for field_path, values in sorted(change_summary.items()):
old_val, new_val = values["old"], values["new"]
lines.append(f" {field_path}:")
lines.append(f" Alt: {old_val}")
lines.append(f" Neu: {new_val}")
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
# ------------------------------------------------------------------
# Email body builders
# ------------------------------------------------------------------
def _build_new_body(
self,
registration: RegistrationData,
registration_id: str,
version: int,
channel: str,
) -> str:
now = datetime.utcnow()
pg = registration.parent_guardian
ec = registration.emergency_contact
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
return (
"===============================================\n"
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
"===============================================\n"
"\n"
f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
f"Kanal: {channel_de}\n"
f"Anmelde-ID: {registration_id} (Version {version})\n"
"\n"
"-----------------------------------------------\n"
"ANGABEN ZUM KIND\n"
"-----------------------------------------------\n"
f"Name: {registration.child.full_name}\n"
f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} "
f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n"
f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n"
"\n"
"-----------------------------------------------\n"
"SPIELGRUPPEN-AUSWAHL\n"
"-----------------------------------------------\n"
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
f"Tage: {self._format_days(registration)}\n"
"\n"
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
"\n"
"-----------------------------------------------\n"
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
"-----------------------------------------------\n"
f"Name: {pg.full_name}\n"
f"Adresse: {pg.street_address}\n"
f" {pg.postal_code} {pg.city}\n"
f"Telefon: {pg.phone}\n"
f"E-Mail: {pg.email}\n"
"\n"
"-----------------------------------------------\n"
"NOTFALLKONTAKT\n"
"-----------------------------------------------\n"
f"Name: {ec.full_name}\n"
f"Telefon: {ec.phone}\n"
"\n"
"===============================================\n"
"\n"
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
)
def _build_update_body(
self,
registration: RegistrationData,
registration_id: str,
version: int,
change_summary: dict,
) -> str:
now = datetime.utcnow()
pg = registration.parent_guardian
return (
"===============================================\n"
"ANMELDUNGS-AKTUALISIERUNG\n"
"===============================================\n"
"\n"
f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
f"Anmelde-ID: {registration_id} (Version {version})\n"
f"Kind: {registration.child.full_name}\n"
f"Eltern-E-Mail: {pg.email}\n"
"\n"
"-----------------------------------------------\n"
"WAS HAT SICH GEÄNDERT\n"
"-----------------------------------------------\n"
f"{self._format_change_summary(change_summary)}\n"
"\n"
"-----------------------------------------------\n"
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
"-----------------------------------------------\n"
f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n"
f"Tage: {self._format_days(registration)}\n"
f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n"
"\n"
f"Elternteil: {pg.full_name}\n"
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
f"Telefon: {pg.phone}\n"
"\n"
"===============================================\n"
"\n"
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
)
# ------------------------------------------------------------------
# SMTP dispatch
# ------------------------------------------------------------------
def _send(
self,
to: list[str],
cc: list[str],
subject: str,
body: str,
reply_to: str = "",
) -> None:
if not self._smtp_host:
logger.warning(
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
to,
cc,
subject,
)
logger.debug("Notification body:\n%s", body)
return
msg = MIMEMultipart("alternative")
msg["From"] = self._from_email
msg["To"] = ", ".join(to)
msg["CC"] = ", ".join(cc)
msg["Subject"] = subject
if reply_to:
msg["Reply-To"] = reply_to
msg.attach(MIMEText(body, "plain", "utf-8"))
all_recipients = to + cc
try:
if self._use_tls:
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
server.starttls()
else:
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
server.login(self._username, self._password)
server.sendmail(self._from_email, all_recipients, msg.as_string())
server.quit()
logger.info("Notification sent to %s", all_recipients)
except Exception:
logger.exception("Failed to send notification to %s", all_recipients)
# ------------------------------------------------------------------
# Parent confirmation email
# ------------------------------------------------------------------
def notify_parent( def notify_parent(
self, self,
registration: RegistrationData, registration: RegistrationData,
@@ -490,7 +140,7 @@ class AdminNotifier:
logger.warning("No parent email in registration — confirmation not sent.") logger.warning("No parent email in registration — confirmation not sent.")
return return
strings = _STRINGS_EN if language == "en" else _STRINGS_DE strings = load_strings(language)
try: try:
qr_png = self._generate_qr_bill_png() qr_png = self._generate_qr_bill_png()
@@ -498,8 +148,9 @@ class AdminNotifier:
logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation") logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation")
qr_png = None qr_png = None
html_body = self._build_parent_html(registration, strings, has_qr=qr_png is not None) ctx = build_parent_context(registration, strings, has_qr=qr_png is not None)
text_body = self._build_parent_text(registration, strings) html_body = render_template("parent_confirmation.html.j2", ctx)
text_body = render_template("parent_confirmation.txt.j2", ctx)
subject = strings["subject"] subject = strings["subject"]
if not self._smtp_host: if not self._smtp_host:
@@ -552,6 +203,23 @@ class AdminNotifier:
except Exception: except Exception:
logger.exception("Failed to send parent confirmation to %s", parent_email) logger.exception("Failed to send parent confirmation to %s", parent_email)
# ------------------------------------------------------------------
# Routing helpers
# ------------------------------------------------------------------
def _recipients_for(self, types: list[str]) -> list[str]:
"""Return To addresses based on which playgroup types are booked."""
recipients = []
if "indoor" in types and self._indoor_email:
recipients.append(self._indoor_email)
if "outdoor" in types and self._outdoor_email:
recipients.append(self._outdoor_email)
return recipients
# ------------------------------------------------------------------
# QR-bill generation
# ------------------------------------------------------------------
@staticmethod @staticmethod
def _generate_qr_bill_png() -> bytes: def _generate_qr_bill_png() -> bytes:
"""Generate a Swiss QR-bill payment QR code as a PNG image. """Generate a Swiss QR-bill payment QR code as a PNG image.
@@ -563,12 +231,12 @@ class AdminNotifier:
PNG image bytes of the QR code. PNG image bytes of the QR code.
""" """
bill = QRBill( bill = QRBill(
account=_QR_IBAN, account=QR_IBAN,
creditor={ creditor={
"name": _QR_PAYEE, "name": QR_PAYEE,
"street": _QR_STREET, "street": QR_STREET,
"pcode": _QR_PCODE, "pcode": QR_PCODE,
"city": _QR_CITY, "city": QR_CITY,
"country": "CH", "country": "CH",
}, },
amount="80.00", amount="80.00",
@@ -602,197 +270,49 @@ class AdminNotifier:
pil_img.save(buf, format="PNG") pil_img.save(buf, format="PNG")
return buf.getvalue() return buf.getvalue()
def _build_parent_html( # ------------------------------------------------------------------
# SMTP dispatch
# ------------------------------------------------------------------
def _send(
self, self,
registration: RegistrationData, to: list[str],
strings: dict, cc: list[str],
has_qr: bool = True, subject: str,
) -> str: body: str,
"""Render the HTML body for the parent confirmation email.""" reply_to: str = "",
pg = registration.parent_guardian ) -> None:
ec = registration.emergency_contact if not self._smtp_host:
ch = registration.child logger.warning(
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
parent_name = pg.full_name or pg.email or "" to,
greeting = strings["greeting"].format(name=parent_name) cc,
dob_display = self._format_dob(ch.date_of_birth or "") subject,
special_needs = ch.special_needs or strings["none"]
pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings)
pg_days = self._format_days_bilingual(registration, strings)
monthly_fee = self._calculate_monthly_fee(registration)
has_indoor = "indoor" in registration.booking.playgroup_types
qr_section = ""
if has_qr:
qr_section = (
'<div style="text-align:center;margin:20px 0;">'
'<img src="cid:qrbill" alt="Swiss QR-Bill" '
'style="max-width:380px;width:100%;border:1px solid #e0e0e0;">'
"</div>"
) )
logger.debug("Notification body:\n%s", body)
return
deposit_row = "" msg = MIMEMultipart("alternative")
if has_indoor: msg["From"] = self._from_email
deposit_row = ( msg["To"] = ", ".join(to)
f"<tr><td style='padding:4px 0;color:#666;width:55%;'>{strings['deposit']}</td>" msg["CC"] = ", ".join(cc)
f"<td>{strings['deposit_amount']}</td></tr>" msg["Subject"] = subject
) if reply_to:
msg["Reply-To"] = reply_to
return f"""<!DOCTYPE html> msg.attach(MIMEText(body, "plain", "utf-8"))
<html lang="{'de' if strings is _STRINGS_DE else 'en'}"> all_recipients = to + cc
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;"> try:
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1> if self._use_tls:
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p> server = smtplib.SMTP(self._smtp_host, self._smtp_port)
</div> server.starttls()
else:
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;"> server.login(self._username, self._password)
server.sendmail(self._from_email, all_recipients, msg.as_string())
<p>{greeting},</p> server.quit()
<p>{strings['intro']}</p> logger.info("Notification sent to %s", all_recipients)
except Exception:
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['child_section']}</h2> logger.exception("Failed to send notification to %s", all_recipients)
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['child_name']}</td><td>{ch.full_name or ''}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['child_dob']}</td><td>{dob_display}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['child_needs']}</td><td>{special_needs}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['booking_section']}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['booking_type']}</td><td>{pg_types}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['booking_days']}</td><td>{pg_days}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['fees_section']}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['monthly_fee']}</td><td>{monthly_fee}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['reg_fee']}</td><td>{strings['reg_fee_amount']}</td></tr>
{deposit_row}
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['payment_section']}</h2>
<p>{strings['payment_intro']}</p>
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
<tr>
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{strings['iban_label']}</td>
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">CH14 0900 0000 4930 8018 8</td>
</tr>
<tr>
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{strings['payee_label']}</td>
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">Familienverein Fällanden Spielgruppen<br>Huebwisstrase 5, 8117 Fällanden</td>
</tr>
<tr>
<td style="padding:8px;color:#666;">{strings['amount_label']}</td>
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
</tr>
</table>
{qr_section}
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['parent_section']}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['parent_name']}</td><td>{pg.full_name or ''}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['parent_address']}</td><td>{pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['parent_phone']}</td><td>{pg.phone or ''}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['parent_email']}</td><td>{pg.email or ''}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['emergency_section']}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['emergency_name']}</td><td>{ec.full_name or ''}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{strings['emergency_phone']}</td><td>{ec.phone or ''}</td></tr>
</table>
<p style="margin-top:24px;white-space:pre-line;">{strings['closing']}</p>
</div>
</body>
</html>"""
def _build_parent_text(self, registration: RegistrationData, strings: dict) -> str:
"""Render the plain-text fallback 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 ""
greeting = strings["greeting"].format(name=parent_name)
dob_display = self._format_dob(ch.date_of_birth or "")
special_needs = ch.special_needs or strings["none"]
pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings)
pg_days = self._format_days_bilingual(registration, strings)
monthly_fee = self._calculate_monthly_fee(registration)
has_indoor = "indoor" in registration.booking.playgroup_types
deposit_line = ""
if has_indoor:
deposit_line = f"{strings['deposit']}: {strings['deposit_amount']}\n"
return (
f"{greeting},\n\n"
f"{strings['intro']}\n\n"
"===============================================\n"
f"{strings['child_section'].upper()}\n"
"===============================================\n"
f"{strings['child_name']}: {ch.full_name or ''}\n"
f"{strings['child_dob']}: {dob_display}\n"
f"{strings['child_needs']}: {special_needs}\n"
"\n"
"===============================================\n"
f"{strings['booking_section'].upper()}\n"
"===============================================\n"
f"{strings['booking_type']}: {pg_types}\n"
f"{strings['booking_days']}: {pg_days}\n"
"\n"
"===============================================\n"
f"{strings['fees_section'].upper()}\n"
"===============================================\n"
f"{strings['monthly_fee']}: {monthly_fee}\n"
f"{strings['reg_fee']}: {strings['reg_fee_amount']}\n"
f"{deposit_line}"
"\n"
"===============================================\n"
f"{strings['payment_section'].upper()}\n"
"===============================================\n"
f"{strings['payment_intro_text']}\n\n"
f" {strings['iban_label']}: CH14 0900 0000 4930 8018 8\n"
f" {strings['payee_label']}: Familienverein Fällanden Spielgruppen\n"
f" Huebwisstrase 5, 8117 Fällanden\n"
f" {strings['amount_label']}: CHF 80.00\n"
"\n"
"===============================================\n"
f"{strings['parent_section'].upper()}\n"
"===============================================\n"
f"{strings['parent_name']}: {pg.full_name or ''}\n"
f"{strings['parent_address']}: {pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}\n"
f"{strings['parent_phone']}: {pg.phone or ''}\n"
f"{strings['parent_email']}: {pg.email or ''}\n"
"\n"
"===============================================\n"
f"{strings['emergency_section'].upper()}\n"
"===============================================\n"
f"{strings['emergency_name']}: {ec.full_name or ''}\n"
f"{strings['emergency_phone']}: {ec.phone or ''}\n"
"\n"
"-----------------------------------------------\n\n"
f"{strings['closing']}\n"
)
@staticmethod
def _format_types_bilingual(types: list[str], strings: dict) -> str:
"""Format playgroup types using the language-specific type map."""
type_map: dict = strings["types"]
labels = [type_map.get(t, t) for t in types]
return ", ".join(labels) if labels else ""
@staticmethod
def _format_days_bilingual(registration: RegistrationData, strings: dict) -> str:
"""Format selected days using the language-specific day map."""
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
)
+18
View File
@@ -0,0 +1,18 @@
"""Jinja2 template renderer for email notifications."""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(enabled_extensions=["html.j2"]),
keep_trailing_newline=True,
)
def render_template(name: str, context: dict) -> str:
"""Render *name* (relative to the templates directory) with *context*."""
return _env.get_template(name).render(**context)
@@ -0,0 +1,42 @@
===============================================
NEUE SPIELGRUPPEN-ANMELDUNG
===============================================
Eingereicht: {{ submitted_date }} um {{ submitted_time }} Uhr (UTC)
Kanal: {{ channel }}
Anmelde-ID: {{ registration_id }} (Version {{ version }})
-----------------------------------------------
ANGABEN ZUM KIND
-----------------------------------------------
Name: {{ child_name }}
Geburtsdatum: {{ child_dob }} (Alter: {{ child_age }})
Bes. Bedürfnisse: {{ child_needs }}
-----------------------------------------------
SPIELGRUPPEN-AUSWAHL
-----------------------------------------------
Art: {{ playgroup_types }}
Tage: {{ days }}
Monatlicher Beitrag: {{ monthly_fee }}
(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)
-----------------------------------------------
ELTERN / ERZIEHUNGSBERECHTIGTE
-----------------------------------------------
Name: {{ parent_name }}
Adresse: {{ parent_street }}
{{ parent_postal_code }} {{ parent_city }}
Telefon: {{ parent_phone }}
E-Mail: {{ parent_email }}
-----------------------------------------------
NOTFALLKONTAKT
-----------------------------------------------
Name: {{ emergency_name }}
Telefon: {{ emergency_phone }}
===============================================
Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.
@@ -0,0 +1,33 @@
===============================================
ANMELDUNGS-AKTUALISIERUNG
===============================================
Aktualisiert: {{ updated_date }} um {{ updated_time }} Uhr (UTC)
Anmelde-ID: {{ registration_id }} (Version {{ version }})
Kind: {{ child_name }}
Eltern-E-Mail: {{ parent_email }}
-----------------------------------------------
WAS HAT SICH GEÄNDERT
-----------------------------------------------
{% for change in changes %}
{{ change.field }}:
Alt: {{ change.old }}
Neu: {{ change.new }}
{% else %}
(keine Änderungen erkannt)
{% endfor %}
-----------------------------------------------
AKTUELLE ANMELDUNG (nach Aktualisierung)
-----------------------------------------------
Spielgruppe: {{ playgroup_types }}
Tage: {{ days }}
Monatl. Beitrag: {{ monthly_fee }}
Elternteil: {{ parent_name }}
Adresse: {{ parent_street }}, {{ parent_postal_code }} {{ parent_city }}
Telefon: {{ parent_phone }}
===============================================
Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;">
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1>
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p>
</div>
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
<p>{{ greeting }},</p>
<p>{{ strings.intro }}</p>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.child_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.child_name }}</td><td>{{ child_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_dob }}</td><td>{{ child_dob }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_needs }}</td><td>{{ child_needs }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.booking_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.booking_type }}</td><td>{{ playgroup_types }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.booking_days }}</td><td>{{ days }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.fees_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.monthly_fee }}</td><td>{{ monthly_fee }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.reg_fee }}</td><td>{{ strings.reg_fee_amount }}</td></tr>
{% if has_indoor %}
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.deposit }}</td><td>{{ strings.deposit_amount }}</td></tr>
{% endif %}
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.payment_section }}</h2>
<p>{{ strings.payment_intro | safe }}</p>
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
<tr>
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{{ strings.iban_label }}</td>
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">{{ iban }}</td>
</tr>
<tr>
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{{ strings.payee_label }}</td>
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">{{ payee }}<br>{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}</td>
</tr>
<tr>
<td style="padding:8px;color:#666;">{{ strings.amount_label }}</td>
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
</tr>
</table>
{% if has_qr %}
<div style="text-align:center;margin:20px 0;">
<img src="cid:qrbill" alt="Swiss QR-Bill" style="max-width:380px;width:100%;border:1px solid #e0e0e0;">
</div>
{% endif %}
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.parent_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.parent_name }}</td><td>{{ parent_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_address }}</td><td>{{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_phone }}</td><td>{{ parent_phone }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_email }}</td><td>{{ parent_email }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.emergency_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.emergency_name }}</td><td>{{ emergency_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.emergency_phone }}</td><td>{{ emergency_phone }}</td></tr>
</table>
<p style="margin-top:24px;white-space:pre-line;">{{ strings.closing }}</p>
</div>
</body>
</html>
@@ -0,0 +1,51 @@
{{ greeting }},
{{ strings.intro }}
===============================================
{{ strings.child_section | upper }}
===============================================
{{ strings.child_name }}: {{ child_name }}
{{ strings.child_dob }}: {{ child_dob }}
{{ strings.child_needs }}: {{ child_needs }}
===============================================
{{ strings.booking_section | upper }}
===============================================
{{ strings.booking_type }}: {{ playgroup_types }}
{{ strings.booking_days }}: {{ days }}
===============================================
{{ strings.fees_section | upper }}
===============================================
{{ strings.monthly_fee }}: {{ monthly_fee }}
{{ strings.reg_fee }}: {{ strings.reg_fee_amount }}
{% if has_indoor %}{{ strings.deposit }}: {{ strings.deposit_amount }}
{% endif %}
===============================================
{{ strings.payment_section | upper }}
===============================================
{{ strings.payment_intro_text }}
{{ strings.iban_label }}: {{ iban }}
{{ strings.payee_label }}: {{ payee }}
{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}
{{ strings.amount_label }}: CHF 80.00
===============================================
{{ strings.parent_section | upper }}
===============================================
{{ strings.parent_name }}: {{ parent_name }}
{{ strings.parent_address }}: {{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}
{{ strings.parent_phone }}: {{ parent_phone }}
{{ strings.parent_email }}: {{ parent_email }}
===============================================
{{ strings.emergency_section | upper }}
===============================================
{{ strings.emergency_name }}: {{ emergency_name }}
{{ strings.emergency_phone }}: {{ emergency_phone }}
-----------------------------------------------
{{ strings.closing }}
+43 -32
View File
@@ -1,11 +1,18 @@
"""Tests for AdminNotifier helper methods.""" """Tests for AdminNotifier and notification helper functions."""
import email import email
from email.header import decode_header from email.header import decode_header
import pytest import pytest
from src.notifications.notifier import AdminNotifier, _STRINGS_DE, _STRINGS_EN from src.notifications.notifier import AdminNotifier
from src.notifications.context import (
calculate_age,
calculate_monthly_fee,
format_types,
load_strings,
)
from src.notifications.renderer import render_template
from src.models.registration import RegistrationData, Booking, BookingDay from src.models.registration import RegistrationData, Booking, BookingDay
@@ -36,53 +43,55 @@ def notifier():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _format_types # format_types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFormatTypes: class TestFormatTypes:
def test_indoor_label(self, notifier): def test_indoor_label(self):
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower() result = format_types(["indoor"])
assert "Innen" in result or "indoor" in result.lower()
def test_outdoor_label(self, notifier): def test_outdoor_label(self):
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower() result = format_types(["outdoor"])
assert "Wald" in result or "outdoor" in result.lower()
def test_both_labels(self, notifier): def test_both_labels(self):
result = notifier._format_types(["indoor", "outdoor"]) result = format_types(["indoor", "outdoor"])
assert len(result) > 0 assert len(result) > 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _calculate_age # calculate_age
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCalculateAge: class TestCalculateAge:
def test_returns_age_string(self, notifier): def test_returns_age_string(self):
result = notifier._calculate_age("2022-01-01") result = calculate_age("2022-01-01")
assert isinstance(result, str) assert isinstance(result, str)
assert len(result) > 0 assert len(result) > 0
def test_invalid_dob_returns_original_string(self, notifier): def test_invalid_dob_returns_original_string(self):
result = notifier._calculate_age("not-a-date") result = calculate_age("not-a-date")
assert result == "not-a-date" assert result == "not-a-date"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _calculate_monthly_fee # calculate_monthly_fee
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCalculateMonthlyFee: class TestCalculateMonthlyFee:
def test_indoor_one_day(self, notifier, complete_registration): def test_indoor_one_day(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["indoor"], playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")], selected_days=[BookingDay(day="monday", type="indoor")],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "130" in fee assert "130" in fee
def test_indoor_two_days(self, notifier, complete_registration): def test_indoor_two_days(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["indoor"], playgroup_types=["indoor"],
selected_days=[ selected_days=[
@@ -90,10 +99,10 @@ class TestCalculateMonthlyFee:
BookingDay(day="wednesday", type="indoor"), BookingDay(day="wednesday", type="indoor"),
], ],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "260" in fee assert "260" in fee
def test_indoor_three_days(self, notifier, complete_registration): def test_indoor_three_days(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["indoor"], playgroup_types=["indoor"],
selected_days=[ selected_days=[
@@ -102,15 +111,15 @@ class TestCalculateMonthlyFee:
BookingDay(day="thursday", type="indoor"), BookingDay(day="thursday", type="indoor"),
], ],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "390" in fee assert "390" in fee
def test_outdoor_one_day(self, notifier, complete_registration): def test_outdoor_one_day(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["outdoor"], playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")], selected_days=[BookingDay(day="monday", type="outdoor")],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "250" in fee assert "250" in fee
@@ -121,7 +130,6 @@ class TestCalculateMonthlyFee:
class TestSend: class TestSend:
def test_send_calls_smtp(self, notifier, mocker): def test_send_calls_smtp(self, notifier, mocker):
# _send uses smtplib.SMTP directly (not as context manager)
mock_smtp_cls = mocker.patch("smtplib.SMTP") mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value mock_server = mock_smtp_cls.return_value
@@ -146,7 +154,7 @@ class TestSend:
) )
call_args = mock_server.sendmail.call_args call_args = mock_server.sendmail.call_args
recipients = call_args[0][1] # positional arg: to_addrs recipients = call_args[0][1]
assert "a@example.com" in recipients assert "a@example.com" in recipients
assert "b@example.com" in recipients assert "b@example.com" in recipients
@@ -235,16 +243,20 @@ class TestNotifyParent:
mock_smtp_cls.assert_not_called() mock_smtp_cls.assert_not_called()
def test_notify_parent_text_body_contains_iban(self, notifier_no_smtp, complete_registration): def test_notify_parent_text_body_contains_iban(self, complete_registration):
"""Plain-text body includes the IBAN so payment is possible without the QR image.""" """Plain-text body includes the IBAN so payment is possible without the QR image."""
text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_DE) strings = load_strings("de")
from src.notifications.context import build_parent_context
ctx = build_parent_context(complete_registration, strings, has_qr=False)
text = render_template("parent_confirmation.txt.j2", ctx)
assert "CH14" in text assert "CH14" in text
def test_notify_parent_text_body_english_contains_iban( def test_notify_parent_text_body_english_contains_iban(self, complete_registration):
self, notifier_no_smtp, complete_registration
):
"""English plain-text body also includes the IBAN.""" """English plain-text body also includes the IBAN."""
text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_EN) strings = load_strings("en")
from src.notifications.context import build_parent_context
ctx = build_parent_context(complete_registration, strings, has_qr=False)
text = render_template("parent_confirmation.txt.j2", ctx)
assert "CH14" in text assert "CH14" in text
@@ -263,5 +275,4 @@ class TestGenerateQrBillPng:
def test_returns_png_signature(self, notifier): def test_returns_png_signature(self, notifier):
"""Output starts with the PNG magic bytes.""" """Output starts with the PNG magic bytes."""
png = notifier._generate_qr_bill_png() png = notifier._generate_qr_bill_png()
# PNG files start with the 8-byte signature \x89PNG\r\n\x1a\n
assert png[:4] == b"\x89PNG" assert png[:4] == b"\x89PNG"