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 = ( - '
Familienverein Fällanden
-{greeting},
-{strings['intro']}
- -| {strings['child_name']} | {ch.full_name or ''} |
| {strings['child_dob']} | {dob_display} |
| {strings['child_needs']} | {special_needs} |
| {strings['booking_type']} | {pg_types} |
| {strings['booking_days']} | {pg_days} |
| {strings['monthly_fee']} | {monthly_fee} |
| {strings['reg_fee']} | {strings['reg_fee_amount']} |
{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 | -
| {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_name']} | {ec.full_name or ''} |
| {strings['emergency_phone']} | {ec.phone or ''} |
{strings['closing']}
- -Familienverein Fällanden
+{{ greeting }},
+{{ strings.intro }}
+ +| {{ strings.child_name }} | {{ child_name }} |
| {{ strings.child_dob }} | {{ child_dob }} |
| {{ strings.child_needs }} | {{ child_needs }} |
| {{ strings.booking_type }} | {{ playgroup_types }} |
| {{ strings.booking_days }} | {{ days }} |
| {{ strings.monthly_fee }} | {{ monthly_fee }} |
| {{ strings.reg_fee }} | {{ strings.reg_fee_amount }} |
| {{ strings.deposit }} | {{ strings.deposit_amount }} |
{{ 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 | +
| {{ 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_name }} | {{ emergency_name }} |
| {{ strings.emergency_phone }} | {{ emergency_phone }} |
{{ strings.closing }}
+ +