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