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",
|
"chainlit>=2.9.6",
|
||||||
"qrbill>=1.2.0",
|
"qrbill>=1.2.0",
|
||||||
"pillow>=12.1.1",
|
"pillow>=12.1.1",
|
||||||
|
"jinja2>=3.0.0",
|
||||||
|
"pyyaml>=6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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 io
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from datetime import date, datetime
|
|
||||||
from email.mime.image import MIMEImage
|
from email.mime.image import MIMEImage
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
@@ -14,136 +13,23 @@ from PIL import Image, ImageDraw
|
|||||||
from qrbill import QRBill
|
from qrbill import QRBill
|
||||||
|
|
||||||
from ..models.registration import RegistrationData
|
from ..models.registration import RegistrationData
|
||||||
|
from .context import (
|
||||||
|
QR_CITY,
|
||||||
|
QR_IBAN,
|
||||||
|
QR_PAYEE,
|
||||||
|
QR_PCODE,
|
||||||
|
QR_STREET,
|
||||||
|
build_admin_new_context,
|
||||||
|
build_admin_update_context,
|
||||||
|
build_parent_context,
|
||||||
|
format_types,
|
||||||
|
load_strings,
|
||||||
|
)
|
||||||
|
from .renderer import render_template
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bilingual string tables for parent confirmation emails
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_STRINGS_DE: dict = {
|
|
||||||
"subject": "Anmeldebestätigung – Spielgruppe Pumuckl",
|
|
||||||
"greeting": "Guten Tag {name}",
|
|
||||||
"intro": (
|
|
||||||
"Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen. "
|
|
||||||
"Hier ist eine Zusammenfassung:"
|
|
||||||
),
|
|
||||||
"child_section": "Angaben zum Kind",
|
|
||||||
"child_name": "Name",
|
|
||||||
"child_dob": "Geburtsdatum",
|
|
||||||
"child_needs": "Besondere Bedürfnisse",
|
|
||||||
"booking_section": "Spielgruppen-Buchung",
|
|
||||||
"booking_type": "Art",
|
|
||||||
"booking_days": "Tage",
|
|
||||||
"fees_section": "Kosten",
|
|
||||||
"monthly_fee": "Monatlicher Beitrag",
|
|
||||||
"reg_fee": "Anmeldegebühr (einmalig, erstes Jahr)",
|
|
||||||
"deposit": "Reinigungsdepot Innen (rückerstattbar)",
|
|
||||||
"parent_section": "Deine Kontaktdaten",
|
|
||||||
"parent_name": "Name",
|
|
||||||
"parent_address": "Adresse",
|
|
||||||
"parent_phone": "Telefon",
|
|
||||||
"parent_email": "E-Mail",
|
|
||||||
"emergency_section": "Notfallkontakt",
|
|
||||||
"emergency_name": "Name",
|
|
||||||
"emergency_phone": "Telefon",
|
|
||||||
"payment_section": "Zahlungsinformationen",
|
|
||||||
"payment_intro": (
|
|
||||||
"Bitte überweise die Anmeldegebühr von <strong>CHF 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:
|
class AdminNotifier:
|
||||||
"""Sends formatted admin notification emails.
|
"""Sends formatted admin notification emails.
|
||||||
|
|
||||||
@@ -200,9 +86,10 @@ class AdminNotifier:
|
|||||||
|
|
||||||
subject = (
|
subject = (
|
||||||
f"Neue Anmeldung: {registration.child.full_name} "
|
f"Neue Anmeldung: {registration.child.full_name} "
|
||||||
f"– {self._format_types(types)}"
|
f"– {format_types(types)}"
|
||||||
)
|
)
|
||||||
body = self._build_new_body(registration, registration_id, version, channel)
|
ctx = build_admin_new_context(registration, registration_id, version, channel)
|
||||||
|
body = render_template("admin_new.txt.j2", ctx)
|
||||||
|
|
||||||
self._send(
|
self._send(
|
||||||
to=to_addresses,
|
to=to_addresses,
|
||||||
@@ -231,7 +118,8 @@ class AdminNotifier:
|
|||||||
return
|
return
|
||||||
|
|
||||||
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
|
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
|
||||||
body = self._build_update_body(registration, registration_id, version, change_summary)
|
ctx = build_admin_update_context(registration, registration_id, version, change_summary)
|
||||||
|
body = render_template("admin_update.txt.j2", ctx)
|
||||||
|
|
||||||
self._send(
|
self._send(
|
||||||
to=to_addresses,
|
to=to_addresses,
|
||||||
@@ -241,244 +129,6 @@ class AdminNotifier:
|
|||||||
reply_to=registration.parent_guardian.email or "",
|
reply_to=registration.parent_guardian.email or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Routing helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _recipients_for(self, types: list[str]) -> list[str]:
|
|
||||||
"""Return To addresses based on which playgroup types are booked."""
|
|
||||||
recipients = []
|
|
||||||
if "indoor" in types and self._indoor_email:
|
|
||||||
recipients.append(self._indoor_email)
|
|
||||||
if "outdoor" in types and self._outdoor_email:
|
|
||||||
recipients.append(self._outdoor_email)
|
|
||||||
return recipients
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Formatting helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_types(types: list[str]) -> str:
|
|
||||||
has_indoor = "indoor" in types
|
|
||||||
has_outdoor = "outdoor" in types
|
|
||||||
if has_indoor and has_outdoor:
|
|
||||||
return "Innen- und Waldspielgruppe"
|
|
||||||
if has_indoor:
|
|
||||||
return "Innenspielgruppe"
|
|
||||||
if has_outdoor:
|
|
||||||
return "Waldspielgruppe"
|
|
||||||
return "Spielgruppe"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calculate_age(dob_str: str) -> str:
|
|
||||||
try:
|
|
||||||
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
|
|
||||||
today = date.today()
|
|
||||||
years = today.year - dob.year - (
|
|
||||||
(today.month, today.day) < (dob.month, dob.day)
|
|
||||||
)
|
|
||||||
months = (today.month - dob.month) % 12
|
|
||||||
return f"{years} Jahre, {months} Monate"
|
|
||||||
except Exception:
|
|
||||||
return dob_str
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_dob(dob_str: str) -> str:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
|
|
||||||
except Exception:
|
|
||||||
return dob_str or ""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calculate_monthly_fee(registration: RegistrationData) -> str:
|
|
||||||
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
|
|
||||||
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
|
|
||||||
fee = 0
|
|
||||||
if indoor_days == 1:
|
|
||||||
fee += 130
|
|
||||||
elif indoor_days == 2:
|
|
||||||
fee += 260
|
|
||||||
elif indoor_days >= 3:
|
|
||||||
fee += 390
|
|
||||||
if outdoor_days >= 1:
|
|
||||||
fee += 250
|
|
||||||
return f"CHF {fee}.-"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_days(registration: RegistrationData) -> str:
|
|
||||||
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
|
|
||||||
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
|
|
||||||
return ", ".join(
|
|
||||||
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
|
||||||
for d in registration.booking.selected_days
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_change_summary(change_summary: dict) -> str:
|
|
||||||
"""Render field changes as a human-readable list."""
|
|
||||||
lines = []
|
|
||||||
for field_path, values in sorted(change_summary.items()):
|
|
||||||
old_val, new_val = values["old"], values["new"]
|
|
||||||
lines.append(f" {field_path}:")
|
|
||||||
lines.append(f" Alt: {old_val}")
|
|
||||||
lines.append(f" Neu: {new_val}")
|
|
||||||
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Email body builders
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_new_body(
|
|
||||||
self,
|
|
||||||
registration: RegistrationData,
|
|
||||||
registration_id: str,
|
|
||||||
version: int,
|
|
||||||
channel: str,
|
|
||||||
) -> str:
|
|
||||||
now = datetime.utcnow()
|
|
||||||
pg = registration.parent_guardian
|
|
||||||
ec = registration.emergency_contact
|
|
||||||
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
|
||||||
|
|
||||||
return (
|
|
||||||
"===============================================\n"
|
|
||||||
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
|
|
||||||
"===============================================\n"
|
|
||||||
"\n"
|
|
||||||
f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
|
||||||
f"Kanal: {channel_de}\n"
|
|
||||||
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
"ANGABEN ZUM KIND\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
f"Name: {registration.child.full_name}\n"
|
|
||||||
f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} "
|
|
||||||
f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n"
|
|
||||||
f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
"SPIELGRUPPEN-AUSWAHL\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
|
|
||||||
f"Tage: {self._format_days(registration)}\n"
|
|
||||||
"\n"
|
|
||||||
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
|
||||||
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
f"Name: {pg.full_name}\n"
|
|
||||||
f"Adresse: {pg.street_address}\n"
|
|
||||||
f" {pg.postal_code} {pg.city}\n"
|
|
||||||
f"Telefon: {pg.phone}\n"
|
|
||||||
f"E-Mail: {pg.email}\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
"NOTFALLKONTAKT\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
f"Name: {ec.full_name}\n"
|
|
||||||
f"Telefon: {ec.phone}\n"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
"\n"
|
|
||||||
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_update_body(
|
|
||||||
self,
|
|
||||||
registration: RegistrationData,
|
|
||||||
registration_id: str,
|
|
||||||
version: int,
|
|
||||||
change_summary: dict,
|
|
||||||
) -> str:
|
|
||||||
now = datetime.utcnow()
|
|
||||||
pg = registration.parent_guardian
|
|
||||||
|
|
||||||
return (
|
|
||||||
"===============================================\n"
|
|
||||||
"ANMELDUNGS-AKTUALISIERUNG\n"
|
|
||||||
"===============================================\n"
|
|
||||||
"\n"
|
|
||||||
f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
|
||||||
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
|
||||||
f"Kind: {registration.child.full_name}\n"
|
|
||||||
f"Eltern-E-Mail: {pg.email}\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
"WAS HAT SICH GEÄNDERT\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
f"{self._format_change_summary(change_summary)}\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
|
|
||||||
"-----------------------------------------------\n"
|
|
||||||
f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n"
|
|
||||||
f"Tage: {self._format_days(registration)}\n"
|
|
||||||
f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
|
||||||
"\n"
|
|
||||||
f"Elternteil: {pg.full_name}\n"
|
|
||||||
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
|
|
||||||
f"Telefon: {pg.phone}\n"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
"\n"
|
|
||||||
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# SMTP dispatch
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _send(
|
|
||||||
self,
|
|
||||||
to: list[str],
|
|
||||||
cc: list[str],
|
|
||||||
subject: str,
|
|
||||||
body: str,
|
|
||||||
reply_to: str = "",
|
|
||||||
) -> None:
|
|
||||||
if not self._smtp_host:
|
|
||||||
logger.warning(
|
|
||||||
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
subject,
|
|
||||||
)
|
|
||||||
logger.debug("Notification body:\n%s", body)
|
|
||||||
return
|
|
||||||
|
|
||||||
msg = MIMEMultipart("alternative")
|
|
||||||
msg["From"] = self._from_email
|
|
||||||
msg["To"] = ", ".join(to)
|
|
||||||
msg["CC"] = ", ".join(cc)
|
|
||||||
msg["Subject"] = subject
|
|
||||||
if reply_to:
|
|
||||||
msg["Reply-To"] = reply_to
|
|
||||||
|
|
||||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
|
||||||
all_recipients = to + cc
|
|
||||||
|
|
||||||
try:
|
|
||||||
if self._use_tls:
|
|
||||||
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
|
||||||
server.starttls()
|
|
||||||
else:
|
|
||||||
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
|
||||||
|
|
||||||
server.login(self._username, self._password)
|
|
||||||
server.sendmail(self._from_email, all_recipients, msg.as_string())
|
|
||||||
server.quit()
|
|
||||||
logger.info("Notification sent to %s", all_recipients)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to send notification to %s", all_recipients)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Parent confirmation email
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def notify_parent(
|
def notify_parent(
|
||||||
self,
|
self,
|
||||||
registration: RegistrationData,
|
registration: RegistrationData,
|
||||||
@@ -490,7 +140,7 @@ class AdminNotifier:
|
|||||||
logger.warning("No parent email in registration — confirmation not sent.")
|
logger.warning("No parent email in registration — confirmation not sent.")
|
||||||
return
|
return
|
||||||
|
|
||||||
strings = _STRINGS_EN if language == "en" else _STRINGS_DE
|
strings = load_strings(language)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
qr_png = self._generate_qr_bill_png()
|
qr_png = self._generate_qr_bill_png()
|
||||||
@@ -498,8 +148,9 @@ class AdminNotifier:
|
|||||||
logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation")
|
logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation")
|
||||||
qr_png = None
|
qr_png = None
|
||||||
|
|
||||||
html_body = self._build_parent_html(registration, strings, has_qr=qr_png is not None)
|
ctx = build_parent_context(registration, strings, has_qr=qr_png is not None)
|
||||||
text_body = self._build_parent_text(registration, strings)
|
html_body = render_template("parent_confirmation.html.j2", ctx)
|
||||||
|
text_body = render_template("parent_confirmation.txt.j2", ctx)
|
||||||
subject = strings["subject"]
|
subject = strings["subject"]
|
||||||
|
|
||||||
if not self._smtp_host:
|
if not self._smtp_host:
|
||||||
@@ -552,6 +203,23 @@ class AdminNotifier:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to send parent confirmation to %s", parent_email)
|
logger.exception("Failed to send parent confirmation to %s", parent_email)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Routing helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _recipients_for(self, types: list[str]) -> list[str]:
|
||||||
|
"""Return To addresses based on which playgroup types are booked."""
|
||||||
|
recipients = []
|
||||||
|
if "indoor" in types and self._indoor_email:
|
||||||
|
recipients.append(self._indoor_email)
|
||||||
|
if "outdoor" in types and self._outdoor_email:
|
||||||
|
recipients.append(self._outdoor_email)
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# QR-bill generation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_qr_bill_png() -> bytes:
|
def _generate_qr_bill_png() -> bytes:
|
||||||
"""Generate a Swiss QR-bill payment QR code as a PNG image.
|
"""Generate a Swiss QR-bill payment QR code as a PNG image.
|
||||||
@@ -563,12 +231,12 @@ class AdminNotifier:
|
|||||||
PNG image bytes of the QR code.
|
PNG image bytes of the QR code.
|
||||||
"""
|
"""
|
||||||
bill = QRBill(
|
bill = QRBill(
|
||||||
account=_QR_IBAN,
|
account=QR_IBAN,
|
||||||
creditor={
|
creditor={
|
||||||
"name": _QR_PAYEE,
|
"name": QR_PAYEE,
|
||||||
"street": _QR_STREET,
|
"street": QR_STREET,
|
||||||
"pcode": _QR_PCODE,
|
"pcode": QR_PCODE,
|
||||||
"city": _QR_CITY,
|
"city": QR_CITY,
|
||||||
"country": "CH",
|
"country": "CH",
|
||||||
},
|
},
|
||||||
amount="80.00",
|
amount="80.00",
|
||||||
@@ -602,197 +270,49 @@ class AdminNotifier:
|
|||||||
pil_img.save(buf, format="PNG")
|
pil_img.save(buf, format="PNG")
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
def _build_parent_html(
|
# ------------------------------------------------------------------
|
||||||
|
# SMTP dispatch
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _send(
|
||||||
self,
|
self,
|
||||||
registration: RegistrationData,
|
to: list[str],
|
||||||
strings: dict,
|
cc: list[str],
|
||||||
has_qr: bool = True,
|
subject: str,
|
||||||
) -> str:
|
body: str,
|
||||||
"""Render the HTML body for the parent confirmation email."""
|
reply_to: str = "",
|
||||||
pg = registration.parent_guardian
|
) -> None:
|
||||||
ec = registration.emergency_contact
|
if not self._smtp_host:
|
||||||
ch = registration.child
|
logger.warning(
|
||||||
|
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
|
||||||
parent_name = pg.full_name or pg.email or ""
|
to,
|
||||||
greeting = strings["greeting"].format(name=parent_name)
|
cc,
|
||||||
dob_display = self._format_dob(ch.date_of_birth or "")
|
subject,
|
||||||
special_needs = ch.special_needs or strings["none"]
|
|
||||||
pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings)
|
|
||||||
pg_days = self._format_days_bilingual(registration, strings)
|
|
||||||
monthly_fee = self._calculate_monthly_fee(registration)
|
|
||||||
has_indoor = "indoor" in registration.booking.playgroup_types
|
|
||||||
|
|
||||||
qr_section = ""
|
|
||||||
if has_qr:
|
|
||||||
qr_section = (
|
|
||||||
'<div style="text-align:center;margin:20px 0;">'
|
|
||||||
'<img src="cid:qrbill" alt="Swiss QR-Bill" '
|
|
||||||
'style="max-width:380px;width:100%;border:1px solid #e0e0e0;">'
|
|
||||||
"</div>"
|
|
||||||
)
|
)
|
||||||
|
logger.debug("Notification body:\n%s", body)
|
||||||
|
return
|
||||||
|
|
||||||
deposit_row = ""
|
msg = MIMEMultipart("alternative")
|
||||||
if has_indoor:
|
msg["From"] = self._from_email
|
||||||
deposit_row = (
|
msg["To"] = ", ".join(to)
|
||||||
f"<tr><td style='padding:4px 0;color:#666;width:55%;'>{strings['deposit']}</td>"
|
msg["CC"] = ", ".join(cc)
|
||||||
f"<td>{strings['deposit_amount']}</td></tr>"
|
msg["Subject"] = subject
|
||||||
)
|
if reply_to:
|
||||||
|
msg["Reply-To"] = reply_to
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
<html lang="{'de' if strings is _STRINGS_DE else 'en'}">
|
all_recipients = to + cc
|
||||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
||||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
|
|
||||||
|
|
||||||
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;">
|
try:
|
||||||
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1>
|
if self._use_tls:
|
||||||
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p>
|
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
||||||
</div>
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
||||||
|
|
||||||
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
|
server.login(self._username, self._password)
|
||||||
|
server.sendmail(self._from_email, all_recipients, msg.as_string())
|
||||||
<p>{greeting},</p>
|
server.quit()
|
||||||
<p>{strings['intro']}</p>
|
logger.info("Notification sent to %s", all_recipients)
|
||||||
|
except Exception:
|
||||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['child_section']}</h2>
|
logger.exception("Failed to send notification to %s", all_recipients)
|
||||||
<table style="width:100%;border-collapse:collapse;">
|
|
||||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['child_name']}</td><td>{ch.full_name or ''}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['child_dob']}</td><td>{dob_display}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['child_needs']}</td><td>{special_needs}</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['booking_section']}</h2>
|
|
||||||
<table style="width:100%;border-collapse:collapse;">
|
|
||||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['booking_type']}</td><td>{pg_types}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['booking_days']}</td><td>{pg_days}</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['fees_section']}</h2>
|
|
||||||
<table style="width:100%;border-collapse:collapse;">
|
|
||||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['monthly_fee']}</td><td>{monthly_fee}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['reg_fee']}</td><td>{strings['reg_fee_amount']}</td></tr>
|
|
||||||
{deposit_row}
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['payment_section']}</h2>
|
|
||||||
<p>{strings['payment_intro']}</p>
|
|
||||||
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{strings['iban_label']}</td>
|
|
||||||
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">CH14 0900 0000 4930 8018 8</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{strings['payee_label']}</td>
|
|
||||||
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">Familienverein Fällanden Spielgruppen<br>Huebwisstrase 5, 8117 Fällanden</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:8px;color:#666;">{strings['amount_label']}</td>
|
|
||||||
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
{qr_section}
|
|
||||||
|
|
||||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['parent_section']}</h2>
|
|
||||||
<table style="width:100%;border-collapse:collapse;">
|
|
||||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['parent_name']}</td><td>{pg.full_name or ''}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['parent_address']}</td><td>{pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['parent_phone']}</td><td>{pg.phone or ''}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['parent_email']}</td><td>{pg.email or ''}</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{strings['emergency_section']}</h2>
|
|
||||||
<table style="width:100%;border-collapse:collapse;">
|
|
||||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{strings['emergency_name']}</td><td>{ec.full_name or ''}</td></tr>
|
|
||||||
<tr><td style="padding:4px 0;color:#666;">{strings['emergency_phone']}</td><td>{ec.phone or ''}</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p style="margin-top:24px;white-space:pre-line;">{strings['closing']}</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
def _build_parent_text(self, registration: RegistrationData, strings: dict) -> str:
|
|
||||||
"""Render the plain-text fallback for the parent confirmation email."""
|
|
||||||
pg = registration.parent_guardian
|
|
||||||
ec = registration.emergency_contact
|
|
||||||
ch = registration.child
|
|
||||||
|
|
||||||
parent_name = pg.full_name or pg.email or ""
|
|
||||||
greeting = strings["greeting"].format(name=parent_name)
|
|
||||||
dob_display = self._format_dob(ch.date_of_birth or "")
|
|
||||||
special_needs = ch.special_needs or strings["none"]
|
|
||||||
pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings)
|
|
||||||
pg_days = self._format_days_bilingual(registration, strings)
|
|
||||||
monthly_fee = self._calculate_monthly_fee(registration)
|
|
||||||
has_indoor = "indoor" in registration.booking.playgroup_types
|
|
||||||
|
|
||||||
deposit_line = ""
|
|
||||||
if has_indoor:
|
|
||||||
deposit_line = f"{strings['deposit']}: {strings['deposit_amount']}\n"
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"{greeting},\n\n"
|
|
||||||
f"{strings['intro']}\n\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['child_section'].upper()}\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['child_name']}: {ch.full_name or ''}\n"
|
|
||||||
f"{strings['child_dob']}: {dob_display}\n"
|
|
||||||
f"{strings['child_needs']}: {special_needs}\n"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['booking_section'].upper()}\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['booking_type']}: {pg_types}\n"
|
|
||||||
f"{strings['booking_days']}: {pg_days}\n"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['fees_section'].upper()}\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['monthly_fee']}: {monthly_fee}\n"
|
|
||||||
f"{strings['reg_fee']}: {strings['reg_fee_amount']}\n"
|
|
||||||
f"{deposit_line}"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['payment_section'].upper()}\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['payment_intro_text']}\n\n"
|
|
||||||
f" {strings['iban_label']}: CH14 0900 0000 4930 8018 8\n"
|
|
||||||
f" {strings['payee_label']}: Familienverein Fällanden Spielgruppen\n"
|
|
||||||
f" Huebwisstrase 5, 8117 Fällanden\n"
|
|
||||||
f" {strings['amount_label']}: CHF 80.00\n"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['parent_section'].upper()}\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['parent_name']}: {pg.full_name or ''}\n"
|
|
||||||
f"{strings['parent_address']}: {pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}\n"
|
|
||||||
f"{strings['parent_phone']}: {pg.phone or ''}\n"
|
|
||||||
f"{strings['parent_email']}: {pg.email or ''}\n"
|
|
||||||
"\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['emergency_section'].upper()}\n"
|
|
||||||
"===============================================\n"
|
|
||||||
f"{strings['emergency_name']}: {ec.full_name or ''}\n"
|
|
||||||
f"{strings['emergency_phone']}: {ec.phone or ''}\n"
|
|
||||||
"\n"
|
|
||||||
"-----------------------------------------------\n\n"
|
|
||||||
f"{strings['closing']}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_types_bilingual(types: list[str], strings: dict) -> str:
|
|
||||||
"""Format playgroup types using the language-specific type map."""
|
|
||||||
type_map: dict = strings["types"]
|
|
||||||
labels = [type_map.get(t, t) for t in types]
|
|
||||||
return ", ".join(labels) if labels else ""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_days_bilingual(registration: RegistrationData, strings: dict) -> str:
|
|
||||||
"""Format selected days using the language-specific day map."""
|
|
||||||
day_map: dict = strings["days"]
|
|
||||||
type_map: dict = strings["types"]
|
|
||||||
return ", ".join(
|
|
||||||
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
|
||||||
for d in registration.booking.selected_days
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
import email
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.notifications.notifier import AdminNotifier, _STRINGS_DE, _STRINGS_EN
|
from src.notifications.notifier import AdminNotifier
|
||||||
|
from src.notifications.context import (
|
||||||
|
calculate_age,
|
||||||
|
calculate_monthly_fee,
|
||||||
|
format_types,
|
||||||
|
load_strings,
|
||||||
|
)
|
||||||
|
from src.notifications.renderer import render_template
|
||||||
from src.models.registration import RegistrationData, Booking, BookingDay
|
from src.models.registration import RegistrationData, Booking, BookingDay
|
||||||
|
|
||||||
|
|
||||||
@@ -36,53 +43,55 @@ def notifier():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _format_types
|
# format_types
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestFormatTypes:
|
class TestFormatTypes:
|
||||||
def test_indoor_label(self, notifier):
|
def test_indoor_label(self):
|
||||||
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
|
result = format_types(["indoor"])
|
||||||
|
assert "Innen" in result or "indoor" in result.lower()
|
||||||
|
|
||||||
def test_outdoor_label(self, notifier):
|
def test_outdoor_label(self):
|
||||||
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
|
result = format_types(["outdoor"])
|
||||||
|
assert "Wald" in result or "outdoor" in result.lower()
|
||||||
|
|
||||||
def test_both_labels(self, notifier):
|
def test_both_labels(self):
|
||||||
result = notifier._format_types(["indoor", "outdoor"])
|
result = format_types(["indoor", "outdoor"])
|
||||||
assert len(result) > 0
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _calculate_age
|
# calculate_age
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestCalculateAge:
|
class TestCalculateAge:
|
||||||
def test_returns_age_string(self, notifier):
|
def test_returns_age_string(self):
|
||||||
result = notifier._calculate_age("2022-01-01")
|
result = calculate_age("2022-01-01")
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
assert len(result) > 0
|
assert len(result) > 0
|
||||||
|
|
||||||
def test_invalid_dob_returns_original_string(self, notifier):
|
def test_invalid_dob_returns_original_string(self):
|
||||||
result = notifier._calculate_age("not-a-date")
|
result = calculate_age("not-a-date")
|
||||||
assert result == "not-a-date"
|
assert result == "not-a-date"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _calculate_monthly_fee
|
# calculate_monthly_fee
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestCalculateMonthlyFee:
|
class TestCalculateMonthlyFee:
|
||||||
def test_indoor_one_day(self, notifier, complete_registration):
|
def test_indoor_one_day(self, complete_registration):
|
||||||
complete_registration.booking = Booking(
|
complete_registration.booking = Booking(
|
||||||
playgroup_types=["indoor"],
|
playgroup_types=["indoor"],
|
||||||
selected_days=[BookingDay(day="monday", type="indoor")],
|
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||||
)
|
)
|
||||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
fee = calculate_monthly_fee(complete_registration)
|
||||||
assert "130" in fee
|
assert "130" in fee
|
||||||
|
|
||||||
def test_indoor_two_days(self, notifier, complete_registration):
|
def test_indoor_two_days(self, complete_registration):
|
||||||
complete_registration.booking = Booking(
|
complete_registration.booking = Booking(
|
||||||
playgroup_types=["indoor"],
|
playgroup_types=["indoor"],
|
||||||
selected_days=[
|
selected_days=[
|
||||||
@@ -90,10 +99,10 @@ class TestCalculateMonthlyFee:
|
|||||||
BookingDay(day="wednesday", type="indoor"),
|
BookingDay(day="wednesday", type="indoor"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
fee = calculate_monthly_fee(complete_registration)
|
||||||
assert "260" in fee
|
assert "260" in fee
|
||||||
|
|
||||||
def test_indoor_three_days(self, notifier, complete_registration):
|
def test_indoor_three_days(self, complete_registration):
|
||||||
complete_registration.booking = Booking(
|
complete_registration.booking = Booking(
|
||||||
playgroup_types=["indoor"],
|
playgroup_types=["indoor"],
|
||||||
selected_days=[
|
selected_days=[
|
||||||
@@ -102,15 +111,15 @@ class TestCalculateMonthlyFee:
|
|||||||
BookingDay(day="thursday", type="indoor"),
|
BookingDay(day="thursday", type="indoor"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
fee = calculate_monthly_fee(complete_registration)
|
||||||
assert "390" in fee
|
assert "390" in fee
|
||||||
|
|
||||||
def test_outdoor_one_day(self, notifier, complete_registration):
|
def test_outdoor_one_day(self, complete_registration):
|
||||||
complete_registration.booking = Booking(
|
complete_registration.booking = Booking(
|
||||||
playgroup_types=["outdoor"],
|
playgroup_types=["outdoor"],
|
||||||
selected_days=[BookingDay(day="monday", type="outdoor")],
|
selected_days=[BookingDay(day="monday", type="outdoor")],
|
||||||
)
|
)
|
||||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
fee = calculate_monthly_fee(complete_registration)
|
||||||
assert "250" in fee
|
assert "250" in fee
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +130,6 @@ class TestCalculateMonthlyFee:
|
|||||||
|
|
||||||
class TestSend:
|
class TestSend:
|
||||||
def test_send_calls_smtp(self, notifier, mocker):
|
def test_send_calls_smtp(self, notifier, mocker):
|
||||||
# _send uses smtplib.SMTP directly (not as context manager)
|
|
||||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||||
mock_server = mock_smtp_cls.return_value
|
mock_server = mock_smtp_cls.return_value
|
||||||
|
|
||||||
@@ -146,7 +154,7 @@ class TestSend:
|
|||||||
)
|
)
|
||||||
|
|
||||||
call_args = mock_server.sendmail.call_args
|
call_args = mock_server.sendmail.call_args
|
||||||
recipients = call_args[0][1] # positional arg: to_addrs
|
recipients = call_args[0][1]
|
||||||
assert "a@example.com" in recipients
|
assert "a@example.com" in recipients
|
||||||
assert "b@example.com" in recipients
|
assert "b@example.com" in recipients
|
||||||
|
|
||||||
@@ -235,16 +243,20 @@ class TestNotifyParent:
|
|||||||
|
|
||||||
mock_smtp_cls.assert_not_called()
|
mock_smtp_cls.assert_not_called()
|
||||||
|
|
||||||
def test_notify_parent_text_body_contains_iban(self, notifier_no_smtp, complete_registration):
|
def test_notify_parent_text_body_contains_iban(self, complete_registration):
|
||||||
"""Plain-text body includes the IBAN so payment is possible without the QR image."""
|
"""Plain-text body includes the IBAN so payment is possible without the QR image."""
|
||||||
text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_DE)
|
strings = load_strings("de")
|
||||||
|
from src.notifications.context import build_parent_context
|
||||||
|
ctx = build_parent_context(complete_registration, strings, has_qr=False)
|
||||||
|
text = render_template("parent_confirmation.txt.j2", ctx)
|
||||||
assert "CH14" in text
|
assert "CH14" in text
|
||||||
|
|
||||||
def test_notify_parent_text_body_english_contains_iban(
|
def test_notify_parent_text_body_english_contains_iban(self, complete_registration):
|
||||||
self, notifier_no_smtp, complete_registration
|
|
||||||
):
|
|
||||||
"""English plain-text body also includes the IBAN."""
|
"""English plain-text body also includes the IBAN."""
|
||||||
text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_EN)
|
strings = load_strings("en")
|
||||||
|
from src.notifications.context import build_parent_context
|
||||||
|
ctx = build_parent_context(complete_registration, strings, has_qr=False)
|
||||||
|
text = render_template("parent_confirmation.txt.j2", ctx)
|
||||||
assert "CH14" in text
|
assert "CH14" in text
|
||||||
|
|
||||||
|
|
||||||
@@ -263,5 +275,4 @@ class TestGenerateQrBillPng:
|
|||||||
def test_returns_png_signature(self, notifier):
|
def test_returns_png_signature(self, notifier):
|
||||||
"""Output starts with the PNG magic bytes."""
|
"""Output starts with the PNG magic bytes."""
|
||||||
png = notifier._generate_qr_bill_png()
|
png = notifier._generate_qr_bill_png()
|
||||||
# PNG files start with the 8-byte signature \x89PNG\r\n\x1a\n
|
|
||||||
assert png[:4] == b"\x89PNG"
|
assert png[:4] == b"\x89PNG"
|
||||||
|
|||||||
Reference in New Issue
Block a user