From 54d88d1d648a8a245617923ae10d4a9da4314898 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 10:05:03 +0000 Subject: [PATCH] refactor notifications to MVC: extract templates, context, and renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pyproject.toml | 2 + src/notifications/context.py | 231 +++++++ src/notifications/i18n/de.yaml | 57 ++ src/notifications/i18n/en.yaml | 57 ++ src/notifications/notifier.py | 648 +++--------------- src/notifications/renderer.py | 18 + src/notifications/templates/admin_new.txt.j2 | 42 ++ .../templates/admin_update.txt.j2 | 33 + .../templates/parent_confirmation.html.j2 | 81 +++ .../templates/parent_confirmation.txt.j2 | 51 ++ tests/test_notifier.py | 75 +- 11 files changed, 699 insertions(+), 596 deletions(-) create mode 100644 src/notifications/context.py create mode 100644 src/notifications/i18n/de.yaml create mode 100644 src/notifications/i18n/en.yaml create mode 100644 src/notifications/renderer.py create mode 100644 src/notifications/templates/admin_new.txt.j2 create mode 100644 src/notifications/templates/admin_update.txt.j2 create mode 100644 src/notifications/templates/parent_confirmation.html.j2 create mode 100644 src/notifications/templates/parent_confirmation.txt.j2 diff --git a/pyproject.toml b/pyproject.toml index 57feb3d..f6f6140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/notifications/context.py b/src/notifications/context.py new file mode 100644 index 0000000..19981d3 --- /dev/null +++ b/src/notifications/context.py @@ -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, + } diff --git a/src/notifications/i18n/de.yaml b/src/notifications/i18n/de.yaml new file mode 100644 index 0000000..18d8bb0 --- /dev/null +++ b/src/notifications/i18n/de.yaml @@ -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 CHF 80.00 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" diff --git a/src/notifications/i18n/en.yaml b/src/notifications/i18n/en.yaml new file mode 100644 index 0000000..0f4dbd9 --- /dev/null +++ b/src/notifications/i18n/en.yaml @@ -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 CHF 80.00 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" diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py index 34b11db..957c330 100644 --- a/src/notifications/notifier.py +++ b/src/notifications/notifier.py @@ -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 CHF 80.00 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 CHF 80.00 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 = ( - '
' - 'Swiss QR-Bill' - "
" + 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"{strings['deposit']}" - f"{strings['deposit_amount']}" - ) + 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""" - - - + msg.attach(MIMEText(body, "plain", "utf-8")) + all_recipients = to + cc -
-

Spielgruppe Pumuckl

-

Familienverein Fällanden

-
+ 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) -
- -

{greeting},

-

{strings['intro']}

- -

{strings['child_section']}

- - - - -
{strings['child_name']}{ch.full_name or ''}
{strings['child_dob']}{dob_display}
{strings['child_needs']}{special_needs}
- -

{strings['booking_section']}

- - - -
{strings['booking_type']}{pg_types}
{strings['booking_days']}{pg_days}
- -

{strings['fees_section']}

- - - - {deposit_row} -
{strings['monthly_fee']}{monthly_fee}
{strings['reg_fee']}{strings['reg_fee_amount']}
- -

{strings['payment_section']}

-

{strings['payment_intro']}

- - - - - - - - - - - - - -
{strings['iban_label']}CH14 0900 0000 4930 8018 8
{strings['payee_label']}Familienverein Fällanden Spielgruppen
Huebwisstrase 5, 8117 Fällanden
{strings['amount_label']}CHF 80.00
- {qr_section} - -

{strings['parent_section']}

- - - - - -
{strings['parent_name']}{pg.full_name or ''}
{strings['parent_address']}{pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}
{strings['parent_phone']}{pg.phone or ''}
{strings['parent_email']}{pg.email or ''}
- -

{strings['emergency_section']}

- - - -
{strings['emergency_name']}{ec.full_name or ''}
{strings['emergency_phone']}{ec.phone or ''}
- -

{strings['closing']}

- -
- -""" - - 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) diff --git a/src/notifications/renderer.py b/src/notifications/renderer.py new file mode 100644 index 0000000..2c820be --- /dev/null +++ b/src/notifications/renderer.py @@ -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) diff --git a/src/notifications/templates/admin_new.txt.j2 b/src/notifications/templates/admin_new.txt.j2 new file mode 100644 index 0000000..2dd9029 --- /dev/null +++ b/src/notifications/templates/admin_new.txt.j2 @@ -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. diff --git a/src/notifications/templates/admin_update.txt.j2 b/src/notifications/templates/admin_update.txt.j2 new file mode 100644 index 0000000..e45ddd9 --- /dev/null +++ b/src/notifications/templates/admin_update.txt.j2 @@ -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. diff --git a/src/notifications/templates/parent_confirmation.html.j2 b/src/notifications/templates/parent_confirmation.html.j2 new file mode 100644 index 0000000..2dd86cf --- /dev/null +++ b/src/notifications/templates/parent_confirmation.html.j2 @@ -0,0 +1,81 @@ + + + + + + + + +
+

Spielgruppe Pumuckl

+

Familienverein Fällanden

+
+ +
+ +

{{ greeting }},

+

{{ strings.intro }}

+ +

{{ strings.child_section }}

+ + + + +
{{ strings.child_name }}{{ child_name }}
{{ strings.child_dob }}{{ child_dob }}
{{ strings.child_needs }}{{ child_needs }}
+ +

{{ strings.booking_section }}

+ + + +
{{ strings.booking_type }}{{ playgroup_types }}
{{ strings.booking_days }}{{ days }}
+ +

{{ strings.fees_section }}

+ + + + {% if has_indoor %} + + {% endif %} +
{{ strings.monthly_fee }}{{ monthly_fee }}
{{ strings.reg_fee }}{{ strings.reg_fee_amount }}
{{ strings.deposit }}{{ strings.deposit_amount }}
+ +

{{ strings.payment_section }}

+

{{ strings.payment_intro | safe }}

+ + + + + + + + + + + + + +
{{ strings.iban_label }}{{ iban }}
{{ strings.payee_label }}{{ payee }}
{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}
{{ strings.amount_label }}CHF 80.00
+ {% if has_qr %} +
+ Swiss QR-Bill +
+ {% endif %} + +

{{ strings.parent_section }}

+ + + + + +
{{ 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 }}

+ + + +
{{ strings.emergency_name }}{{ emergency_name }}
{{ strings.emergency_phone }}{{ emergency_phone }}
+ +

{{ strings.closing }}

+ +
+ + diff --git a/src/notifications/templates/parent_confirmation.txt.j2 b/src/notifications/templates/parent_confirmation.txt.j2 new file mode 100644 index 0000000..1c26c28 --- /dev/null +++ b/src/notifications/templates/parent_confirmation.txt.j2 @@ -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 }} diff --git a/tests/test_notifier.py b/tests/test_notifier.py index abb08d1..03c598e 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -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"