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",
"qrbill>=1.2.0",
"pillow>=12.1.1",
"jinja2>=3.0.0",
"pyyaml>=6.0.0",
]
[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 logging
import smtplib
from datetime import date, datetime
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -14,136 +13,23 @@ from PIL import Image, ImageDraw
from qrbill import QRBill
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__)
# ---------------------------------------------------------------------------
# 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:
"""Sends formatted admin notification emails.
@@ -200,9 +86,10 @@ class AdminNotifier:
subject = (
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(
to=to_addresses,
@@ -231,7 +118,8 @@ class AdminNotifier:
return
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(
to=to_addresses,
@@ -241,244 +129,6 @@ class AdminNotifier:
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(
self,
registration: RegistrationData,
@@ -490,7 +140,7 @@ class AdminNotifier:
logger.warning("No parent email in registration — confirmation not sent.")
return
strings = _STRINGS_EN if language == "en" else _STRINGS_DE
strings = load_strings(language)
try:
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")
qr_png = None
html_body = self._build_parent_html(registration, strings, has_qr=qr_png is not None)
text_body = self._build_parent_text(registration, strings)
ctx = build_parent_context(registration, strings, has_qr=qr_png is not None)
html_body = render_template("parent_confirmation.html.j2", ctx)
text_body = render_template("parent_confirmation.txt.j2", ctx)
subject = strings["subject"]
if not self._smtp_host:
@@ -552,6 +203,23 @@ class AdminNotifier:
except Exception:
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
def _generate_qr_bill_png() -> bytes:
"""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.
"""
bill = QRBill(
account=_QR_IBAN,
account=QR_IBAN,
creditor={
"name": _QR_PAYEE,
"street": _QR_STREET,
"pcode": _QR_PCODE,
"city": _QR_CITY,
"name": QR_PAYEE,
"street": QR_STREET,
"pcode": QR_PCODE,
"city": QR_CITY,
"country": "CH",
},
amount="80.00",
@@ -602,197 +270,49 @@ class AdminNotifier:
pil_img.save(buf, format="PNG")
return buf.getvalue()
def _build_parent_html(
# ------------------------------------------------------------------
# SMTP dispatch
# ------------------------------------------------------------------
def _send(
self,
registration: RegistrationData,
strings: dict,
has_qr: bool = True,
) -> str:
"""Render the HTML body 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
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>"
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
deposit_row = ""
if has_indoor:
deposit_row = (
f"<tr><td style='padding:4px 0;color:#666;width:55%;'>{strings['deposit']}</td>"
f"<td>{strings['deposit_amount']}</td></tr>"
)
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
return f"""<!DOCTYPE html>
<html lang="{'de' if strings is _STRINGS_DE else 'en'}">
<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;">
msg.attach(MIMEText(body, "plain", "utf-8"))
all_recipients = to + cc
<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>
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)
<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>{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
)
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)
+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
from email.header import decode_header
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
@@ -36,53 +43,55 @@ def notifier():
# ---------------------------------------------------------------------------
# _format_types
# format_types
# ---------------------------------------------------------------------------
class TestFormatTypes:
def test_indoor_label(self, notifier):
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
def test_indoor_label(self):
result = format_types(["indoor"])
assert "Innen" in result or "indoor" in result.lower()
def test_outdoor_label(self, notifier):
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
def test_outdoor_label(self):
result = format_types(["outdoor"])
assert "Wald" in result or "outdoor" in result.lower()
def test_both_labels(self, notifier):
result = notifier._format_types(["indoor", "outdoor"])
def test_both_labels(self):
result = format_types(["indoor", "outdoor"])
assert len(result) > 0
# ---------------------------------------------------------------------------
# _calculate_age
# calculate_age
# ---------------------------------------------------------------------------
class TestCalculateAge:
def test_returns_age_string(self, notifier):
result = notifier._calculate_age("2022-01-01")
def test_returns_age_string(self):
result = calculate_age("2022-01-01")
assert isinstance(result, str)
assert len(result) > 0
def test_invalid_dob_returns_original_string(self, notifier):
result = notifier._calculate_age("not-a-date")
def test_invalid_dob_returns_original_string(self):
result = calculate_age("not-a-date")
assert result == "not-a-date"
# ---------------------------------------------------------------------------
# _calculate_monthly_fee
# calculate_monthly_fee
# ---------------------------------------------------------------------------
class TestCalculateMonthlyFee:
def test_indoor_one_day(self, notifier, complete_registration):
def test_indoor_one_day(self, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["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
def test_indoor_two_days(self, notifier, complete_registration):
def test_indoor_two_days(self, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
@@ -90,10 +99,10 @@ class TestCalculateMonthlyFee:
BookingDay(day="wednesday", type="indoor"),
],
)
fee = notifier._calculate_monthly_fee(complete_registration)
fee = calculate_monthly_fee(complete_registration)
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(
playgroup_types=["indoor"],
selected_days=[
@@ -102,15 +111,15 @@ class TestCalculateMonthlyFee:
BookingDay(day="thursday", type="indoor"),
],
)
fee = notifier._calculate_monthly_fee(complete_registration)
fee = calculate_monthly_fee(complete_registration)
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(
playgroup_types=["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
@@ -121,7 +130,6 @@ class TestCalculateMonthlyFee:
class TestSend:
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_server = mock_smtp_cls.return_value
@@ -146,7 +154,7 @@ class TestSend:
)
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 "b@example.com" in recipients
@@ -235,16 +243,20 @@ class TestNotifyParent:
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."""
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
def test_notify_parent_text_body_english_contains_iban(
self, notifier_no_smtp, complete_registration
):
def test_notify_parent_text_body_english_contains_iban(self, complete_registration):
"""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
@@ -263,5 +275,4 @@ class TestGenerateQrBillPng:
def test_returns_png_signature(self, notifier):
"""Output starts with the PNG magic bytes."""
png = notifier._generate_qr_bill_png()
# PNG files start with the 8-byte signature \x89PNG\r\n\x1a\n
assert png[:4] == b"\x89PNG"