2026-02-20 22:15:49 +00:00
|
|
|
|
"""Admin email notifications — new registrations and registration updates."""
|
2026-02-20 20:17:00 +00:00
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import smtplib
|
|
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
|
|
from email.mime.text import MIMEText
|
|
|
|
|
|
|
|
|
|
|
|
from ..models.registration import RegistrationData
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# Notification routing per spec
|
|
|
|
|
|
_INDOOR_EMAIL = "andrea.sigrist@gmx.net"
|
|
|
|
|
|
_OUTDOOR_EMAIL = "baba.laeubli@gmail.com"
|
|
|
|
|
|
_ADMIN_CC_EMAIL = "spielgruppen@familien-verein.ch"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminNotifier:
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"""Sends formatted admin notification emails.
|
2026-02-20 20:17:00 +00:00
|
|
|
|
|
2026-02-20 22:15:49 +00:00
|
|
|
|
Handles two notification types:
|
|
|
|
|
|
- New registration completed → "New Registration: …"
|
|
|
|
|
|
- Existing registration updated → "Registration Updated: …" (with field diff)
|
|
|
|
|
|
|
|
|
|
|
|
When *smtp_host* is empty the notifier logs and skips sending (dev mode).
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
smtp_host: str,
|
|
|
|
|
|
smtp_port: int,
|
|
|
|
|
|
username: str,
|
|
|
|
|
|
password: str,
|
|
|
|
|
|
use_tls: bool = True,
|
|
|
|
|
|
from_email: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self._smtp_host = smtp_host
|
|
|
|
|
|
self._smtp_port = smtp_port
|
|
|
|
|
|
self._username = username
|
|
|
|
|
|
self._password = password
|
|
|
|
|
|
self._use_tls = use_tls
|
|
|
|
|
|
self._from_email = from_email or username
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Public API
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def notify_admin(
|
|
|
|
|
|
self,
|
|
|
|
|
|
registration: RegistrationData,
|
|
|
|
|
|
registration_id: str,
|
2026-02-20 22:15:49 +00:00
|
|
|
|
version: int,
|
2026-02-20 20:17:00 +00:00
|
|
|
|
conversation_id: str,
|
|
|
|
|
|
channel: str,
|
|
|
|
|
|
) -> None:
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"""Send notification for a newly completed registration (version 1)."""
|
2026-02-20 20:17:00 +00:00
|
|
|
|
types = registration.booking.playgroup_types
|
2026-02-20 22:15:49 +00:00
|
|
|
|
to_addresses = self._recipients_for(types)
|
2026-02-20 20:17:00 +00:00
|
|
|
|
|
|
|
|
|
|
subject = (
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Neue Anmeldung: {registration.child.full_name} "
|
|
|
|
|
|
f"– {self._format_types(types)}"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
)
|
2026-02-20 22:15:49 +00:00
|
|
|
|
body = self._build_new_body(registration, registration_id, version, channel)
|
|
|
|
|
|
|
|
|
|
|
|
self._send(
|
|
|
|
|
|
to=to_addresses,
|
|
|
|
|
|
cc=[_ADMIN_CC_EMAIL],
|
|
|
|
|
|
subject=subject,
|
|
|
|
|
|
body=body,
|
|
|
|
|
|
reply_to=registration.parent_guardian.email or "",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def notify_registration_update(
|
|
|
|
|
|
self,
|
|
|
|
|
|
registration: RegistrationData,
|
|
|
|
|
|
registration_id: str,
|
|
|
|
|
|
version: int,
|
|
|
|
|
|
change_summary: dict,
|
|
|
|
|
|
conversation_id: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Send notification when an existing registration is updated."""
|
|
|
|
|
|
types = registration.booking.playgroup_types
|
|
|
|
|
|
to_addresses = self._recipients_for(types)
|
|
|
|
|
|
|
2026-02-21 21:13:19 +00:00
|
|
|
|
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
body = self._build_update_body(registration, registration_id, version, change_summary)
|
2026-02-20 20:17:00 +00:00
|
|
|
|
|
|
|
|
|
|
self._send(
|
|
|
|
|
|
to=to_addresses,
|
|
|
|
|
|
cc=[_ADMIN_CC_EMAIL],
|
|
|
|
|
|
subject=subject,
|
|
|
|
|
|
body=body,
|
|
|
|
|
|
reply_to=registration.parent_guardian.email or "",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Formatting helpers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
2026-02-20 22:15:49 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _recipients_for(types: list[str]) -> list[str]:
|
|
|
|
|
|
recipients = []
|
|
|
|
|
|
if "indoor" in types:
|
|
|
|
|
|
recipients.append(_INDOOR_EMAIL)
|
|
|
|
|
|
if "outdoor" in types:
|
|
|
|
|
|
recipients.append(_OUTDOOR_EMAIL)
|
|
|
|
|
|
return recipients
|
|
|
|
|
|
|
2026-02-20 20:17:00 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_types(types: list[str]) -> str:
|
|
|
|
|
|
has_indoor = "indoor" in types
|
|
|
|
|
|
has_outdoor = "outdoor" in types
|
|
|
|
|
|
if has_indoor and has_outdoor:
|
2026-02-21 21:13:19 +00:00
|
|
|
|
return "Innen- und Waldspielgruppe"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
if has_indoor:
|
2026-02-21 21:13:19 +00:00
|
|
|
|
return "Innenspielgruppe"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
if has_outdoor:
|
2026-02-21 21:13:19 +00:00
|
|
|
|
return "Waldspielgruppe"
|
|
|
|
|
|
return "Spielgruppe"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
|
|
|
|
|
|
@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
|
2026-02-21 21:13:19 +00:00
|
|
|
|
return f"{years} Jahre, {months} Monate"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
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:
|
2026-02-21 21:13:19 +00:00
|
|
|
|
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
|
|
|
|
|
|
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
|
2026-02-20 20:17:00 +00:00
|
|
|
|
return ", ".join(
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
for d in registration.booking.selected_days
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-20 22:15:49 +00:00
|
|
|
|
@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}:")
|
2026-02-21 21:13:19 +00:00
|
|
|
|
lines.append(f" Alt: {old_val}")
|
|
|
|
|
|
lines.append(f" Neu: {new_val}")
|
|
|
|
|
|
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Email body builders
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _build_new_body(
|
2026-02-20 20:17:00 +00:00
|
|
|
|
self,
|
|
|
|
|
|
registration: RegistrationData,
|
|
|
|
|
|
registration_id: str,
|
2026-02-20 22:15:49 +00:00
|
|
|
|
version: int,
|
2026-02-20 20:17:00 +00:00
|
|
|
|
channel: str,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
now = datetime.utcnow()
|
|
|
|
|
|
pg = registration.parent_guardian
|
|
|
|
|
|
ec = registration.emergency_contact
|
2026-02-21 21:13:19 +00:00
|
|
|
|
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
2026-02-20 20:17:00 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
"===============================================\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"===============================================\n"
|
|
|
|
|
|
"\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
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"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"ANGABEN ZUM KIND\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"-----------------------------------------------\n"
|
|
|
|
|
|
f"Name: {registration.child.full_name}\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
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"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"SPIELGRUPPEN-AUSWAHL\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
|
|
|
|
|
|
f"Tage: {self._format_days(registration)}\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
|
|
|
|
|
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"-----------------------------------------------\n"
|
|
|
|
|
|
f"Name: {pg.full_name}\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Adresse: {pg.street_address}\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
f" {pg.postal_code} {pg.city}\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Telefon: {pg.phone}\n"
|
|
|
|
|
|
f"E-Mail: {pg.email}\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"NOTFALLKONTAKT\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"-----------------------------------------------\n"
|
|
|
|
|
|
f"Name: {ec.full_name}\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Telefon: {ec.phone}\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"===============================================\n"
|
|
|
|
|
|
"\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
2026-02-20 20:17:00 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-20 22:15:49 +00:00
|
|
|
|
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"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"ANMELDUNGS-AKTUALISIERUNG\n"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"===============================================\n"
|
|
|
|
|
|
"\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
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"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"WAS HAT SICH GEÄNDERT\n"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"-----------------------------------------------\n"
|
|
|
|
|
|
f"{self._format_change_summary(change_summary)}\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"-----------------------------------------------\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
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"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
f"Elternteil: {pg.full_name}\n"
|
|
|
|
|
|
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
|
|
|
|
|
|
f"Telefon: {pg.phone}\n"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
"\n"
|
|
|
|
|
|
"===============================================\n"
|
|
|
|
|
|
"\n"
|
2026-02-21 21:13:19 +00:00
|
|
|
|
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
2026-02-20 22:15:49 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-20 20:17:00 +00:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 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()
|
2026-02-20 22:15:49 +00:00
|
|
|
|
logger.info("Notification sent to %s", all_recipients)
|
2026-02-20 20:17:00 +00:00
|
|
|
|
except Exception:
|
2026-02-20 22:15:49 +00:00
|
|
|
|
logger.exception("Failed to send notification to %s", all_recipients)
|