From c6fd7497aa2c4931ce6ba902b2d5fc2a0caa5c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:44:12 +0000 Subject: [PATCH 01/12] scaffold registration-confirmation-email change Creates the OpenSpec change directory for sending HTML confirmation emails to parents on registration completion, including payment instructions and Swiss QR-bill code. https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- openspec/changes/registration-confirmation-email/.openspec.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 openspec/changes/registration-confirmation-email/.openspec.yaml diff --git a/openspec/changes/registration-confirmation-email/.openspec.yaml b/openspec/changes/registration-confirmation-email/.openspec.yaml new file mode 100644 index 0000000..cbbb578 --- /dev/null +++ b/openspec/changes/registration-confirmation-email/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-22 From e8cf17000719cb57d8375414da62362f49c194dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:46:24 +0000 Subject: [PATCH 02/12] add proposal for registration-confirmation-email Parents currently receive no confirmation after submitting a registration. This change adds an HTML confirmation email with full registration summary, payment instructions, and a Swiss QR-bill for the CHF 80 registration fee. https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- .../proposal.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 openspec/changes/registration-confirmation-email/proposal.md diff --git a/openspec/changes/registration-confirmation-email/proposal.md b/openspec/changes/registration-confirmation-email/proposal.md new file mode 100644 index 0000000..56c36a9 --- /dev/null +++ b/openspec/changes/registration-confirmation-email/proposal.md @@ -0,0 +1,31 @@ +## Why + +When a parent completes registration, they currently receive no confirmation. They have no record of what they submitted, no clarity on next steps, and no way to pay the registration fee without separately asking for bank details. This creates uncertainty for parents and additional back-and-forth for admins. + +A confirmation email closes this gap: the parent gets a clear summary of their registration, knows exactly what they agreed to, and can pay immediately using the included Swiss QR-bill. + +## What Changes + +- **Send HTML confirmation email to the parent** immediately when a registration is completed and stored +- **Include full registration summary** — all fields the parent filled out, formatted clearly +- **Include payment instructions** (German text) for the CHF 80 registration fee with IBAN and payee details +- **Include a Swiss QR-bill** (payment QR code) so the parent can pay directly from their banking app or print-to-pay +- The confirmation is sent in the **same language** the parent used during the conversation (German or English), but the QR-bill and payment block are always in German (banking standard) + +## Capabilities + +### Modified Capabilities + +- `registration-notifications`: Currently only notifies admins. Extended to also send a confirmation to the parent's email address upon completion. + +### New Capabilities + +*None — this extends an existing capability* + +## Impact + +- **Parents**: Receive immediate, clear confirmation with everything they need — what was registered and how to pay. No need to ask for bank details. +- **Admins**: Fewer follow-up inquiries about "did my registration go through?" and "where do I pay?". Payment is initiated earlier. +- **Email deliverability**: System must send to parent email, not just admin addresses. Parent email is already a required field in the registration schema. +- **Swiss QR-bill generation**: Requires a library to generate the QR code image from the payment data (IBAN, amount, payee address). The QR code is embedded inline in the HTML email. +- **Bilingual**: Confirmation body adapts to the parent's language. The payment section uses German regardless (Swiss QR-bill standard). From 42a01a011ec684136bbf97ca947903a424f048f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:49:43 +0000 Subject: [PATCH 03/12] add design for registration-confirmation-email Covers: notify_parent() on AdminNotifier, multipart HTML email with inline Swiss QR-bill (qrbill library), CHF 80 fixed payment data, German default language, best-effort error handling. https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- .../registration-confirmation-email/design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 openspec/changes/registration-confirmation-email/design.md diff --git a/openspec/changes/registration-confirmation-email/design.md b/openspec/changes/registration-confirmation-email/design.md new file mode 100644 index 0000000..881241a --- /dev/null +++ b/openspec/changes/registration-confirmation-email/design.md @@ -0,0 +1,97 @@ +## Context + +When a registration is completed, `AdminNotifier.notify_admin()` sends an email to the relevant playgroup leaders. The parent receives nothing — no acknowledgement, no summary, no payment details. + +The existing `AdminNotifier` in `src/notifications/notifier.py` handles both new-registration and update notifications to admins. It uses `MIMEMultipart("alternative")` and sends via the configured SMTP server. All the SMTP plumbing already works. + +This change adds a parent-facing confirmation email triggered at the same point as the admin notification. + +## Goals / Non-Goals + +**Goals:** +- Parent receives an HTML confirmation email immediately after registration is stored +- Email contains full registration summary (all 13 fields) +- Email contains payment instructions for the CHF 80 registration fee +- Email contains a Swiss QR-bill (QR code image) embedded inline so the parent can pay via banking app or print + +**Non-Goals:** +- Translating the payment section into English (Swiss QR-bill and bank instructions are always in German regardless of language) +- Sending a reminder if the parent hasn't paid (payment tracking is out of scope) +- Generating a full PDF invoice (QR code embedded in HTML email is sufficient) +- Sibling discount handling in the QR-bill amount (CHF 80 is always fixed for the registration fee) + +## Decisions + +### 1. Extend `AdminNotifier` vs. New Class + +**Decision**: Add `notify_parent()` to the existing `AdminNotifier` class (renamed conceptually; kept in the same file for now). + +**Rationale**: The SMTP plumbing (`_send`, `_smtp_host`, credentials) is already there. A `notify_parent` method reuses all of it. Splitting into a separate class would require duplicating constructor parameters and SMTP setup for no structural benefit at this stage. + +**Trade-off**: `AdminNotifier` becomes slightly misnamed. Acceptable — the class handles all outbound notification emails. Rename in a future refactor if needed. + +### 2. HTML Email Format + +**Decision**: Send `multipart/alternative` with both plain-text and HTML parts. The HTML part is the primary view; plain-text is fallback. + +**Rationale**: Matches the existing `_send` method's `MIMEMultipart("alternative")` pattern. HTML is needed to embed the QR-bill image inline. + +**QR image embedding**: Use `multipart/related` wrapping the HTML part, with the QR PNG attached as `Content-ID` (`cid:qrbill`). This is the standard approach for inline images that don't appear as attachments. + +**Structure:** +``` +multipart/mixed +└── multipart/alternative + ├── text/plain (fallback) + └── multipart/related + ├── text/html (references cid:qrbill) + └── image/png (Content-ID: qrbill, inline) +``` + +### 3. Swiss QR-Bill Generation + +**Decision**: Use the `qrbill` Python library to generate the QR code image. + +**Rationale**: `qrbill` implements the Swiss QR-bill standard (SIX Group spec) directly. It accepts IBAN, payee address, amount, and currency, and outputs an SVG or PNG. No external services required. + +**Fixed payment data** (hardcoded in the notifier, not in config — this is stable bank data): +- IBAN: `CH14 0900 0000 4930 8018 8` +- Payee: Familienverein Fällanden Spielgruppen, c/o Markus Graf, Huebwisstrase 5, 8117 Fällanden +- Amount: CHF 80.00 +- Currency: CHF +- Reference type: NON (no structured reference) + +**Output**: PNG bytes, embedded as inline image in HTML email. + +**Dependency**: Add `qrbill` to `pyproject.toml` dependencies. + +### 4. Language + +**Decision**: Confirmation email body in German by default. The payment block is always German. + +**Rationale**: The registration schema does not store the parent's detected language. German is the default for all admin-facing and parent-facing content. Adding language detection to the registration model is out of scope for this change. + +**Future improvement**: Store `language` in `metadata` during conversation and pass it to the notifier so the body can be in English when detected. + +### 5. Trigger Point + +**Decision**: Call `notify_parent()` immediately after `notify_admin()` at the same trigger site — wherever `notify_admin` is currently called (in `src/agent/core.py` or equivalent). + +**Rationale**: The parent notification is a direct consequence of the same event (registration completed). No separate trigger or queue needed. + +**Error isolation**: If the parent email fails, log the error but do not fail the registration or block the admin notification. Both notifications are best-effort. + +## Risks / Trade-offs + +**`qrbill` library maturity**: Actively maintained, used in production Swiss applications. Risk is low. If the library is unavailable, the QR code can be omitted and the plain IBAN text still enables payment. + +**Inline image rendering**: Some email clients block inline images by default (Outlook, some mobile clients). The plain-text fallback and the raw IBAN text in the HTML body ensure the payment info is always readable even if the QR image is blocked. + +**SMTP failure for parent email**: Parent notification is non-critical (the registration is already stored). Failure is logged as a warning, not an exception. + +**Language mismatch**: A parent who spoke English during chat will receive a German confirmation. Acceptable for MVP given the Swiss German context and the fact that the payment instructions must be in German regardless. + +## Open Questions + +- Should the confirmation email also include the monthly subscription fee (in addition to the CHF 80 registration fee), or only the registration fee QR-bill? The CHF 80 one-time fee is the immediate action required; the monthly fee is recurring and not yet payable. **Proposed answer: include both as informational text, but the QR-bill is for CHF 80 only.** +- Should the parent's email be CC'd on the admin notification, or kept as a separate send? **Proposed answer: separate send — keeps admin and parent content distinct.** From 9981a9121e89d208c392cd192cf07fb2bc64905b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:58:17 +0000 Subject: [PATCH 04/12] update design: match confirmation email language to parent's language - Narrow the Non-Goal to just the QR-bill slip labels (SIX standard), not the surrounding email body - Add language field to RegistrationData via new metadata dataclass, aligned with registration-schema.json metadata object - Confirmation email body rendered in detected language (de/en) - Two template dicts in notifier; admin notifications stay German-only - Remove language-mismatch risk entry; replace with detection-accuracy note https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- .../registration-confirmation-email/design.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openspec/changes/registration-confirmation-email/design.md b/openspec/changes/registration-confirmation-email/design.md index 881241a..64e9958 100644 --- a/openspec/changes/registration-confirmation-email/design.md +++ b/openspec/changes/registration-confirmation-email/design.md @@ -15,7 +15,7 @@ This change adds a parent-facing confirmation email triggered at the same point - Email contains a Swiss QR-bill (QR code image) embedded inline so the parent can pay via banking app or print **Non-Goals:** -- Translating the payment section into English (Swiss QR-bill and bank instructions are always in German regardless of language) +- Translating the QR-bill slip labels themselves (the SIX Group standard mandates German/French/Italian for the payment slip fields — surrounding email text is translated, but the slip is not) - Sending a reminder if the parent hasn't paid (payment tracking is out of scope) - Generating a full PDF invoice (QR code embedded in HTML email is sufficient) - Sibling discount handling in the QR-bill amount (CHF 80 is always fixed for the registration fee) @@ -67,11 +67,15 @@ multipart/mixed ### 4. Language -**Decision**: Confirmation email body in German by default. The payment block is always German. +**Decision**: Add a `language` field to `RegistrationData` (default `"de"`). The agent sets it when it detects the parent's language during conversation. The confirmation email body is rendered in the stored language. The QR-bill slip labels are fixed German/French/Italian per the SIX Group standard and are not translated. -**Rationale**: The registration schema does not store the parent's detected language. German is the default for all admin-facing and parent-facing content. Adding language detection to the registration model is out of scope for this change. +**Supported values**: `"de"` (German, default) and `"en"` (English). Other values fall back to `"de"`. -**Future improvement**: Store `language` in `metadata` during conversation and pass it to the notifier so the body can be in English when detected. +**Where it lives in the model**: A new `metadata` field on `RegistrationData` (a `Metadata` dataclass) with fields `submitted_at`, `channel`, `conversation_id`, and `language`. This also aligns with the JSON schema in `registration-schema.json` which already defines a `metadata` object with those keys. The `language` field is added to both the Python model and the JSON schema. + +**Template strategy**: Two string-template dicts (one per language) for all user-visible strings in the confirmation email. The notifier selects the dict based on `registration.metadata.language`. Admin notifications remain German-only (admins are Swiss German speakers). + +**Rationale**: Parents who conversed in English reasonably expect an English confirmation. Storing language in the model (rather than passing it as a parameter) means it's persisted with the registration and available for future use (e.g. update notifications, reminders). ### 5. Trigger Point @@ -89,7 +93,7 @@ multipart/mixed **SMTP failure for parent email**: Parent notification is non-critical (the registration is already stored). Failure is logged as a warning, not an exception. -**Language mismatch**: A parent who spoke English during chat will receive a German confirmation. Acceptable for MVP given the Swiss German context and the fact that the payment instructions must be in German regardless. +**Language detection accuracy**: The agent infers language from conversation content. Misdetection is possible but low-risk — a parent who receives a German email when they expected English can still understand the registration summary. The QR-bill is universally recognisable regardless of surrounding language. ## Open Questions From 1632433b6af573dc0f72eb26d3e3e6b32431281f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:05:32 +0000 Subject: [PATCH 05/12] add specs and tasks for registration-confirmation-email specs/registration-notifications/spec.md: - MODIFIED: parent confirmation email sent on completion (alongside admin) - Bilingual body (de/en), QR-bill embedded inline, plain-text fallback - Language persisted as metadata.language in registration record tasks.md: - 6 sections: qrbill dep, language persistence, notify_parent() impl, wire into both completion sites (email agent + chat), tests, smoke test https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- .../specs/registration-notifications/spec.md | 81 +++++++++++++++++++ .../registration-confirmation-email/tasks.md | 52 ++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 openspec/changes/registration-confirmation-email/specs/registration-notifications/spec.md create mode 100644 openspec/changes/registration-confirmation-email/tasks.md diff --git a/openspec/changes/registration-confirmation-email/specs/registration-notifications/spec.md b/openspec/changes/registration-confirmation-email/specs/registration-notifications/spec.md new file mode 100644 index 0000000..ff9e3b9 --- /dev/null +++ b/openspec/changes/registration-confirmation-email/specs/registration-notifications/spec.md @@ -0,0 +1,81 @@ +## MODIFIED Requirements + +### Requirement: Notify parent on completed registration +The system SHALL send an HTML confirmation email to the parent immediately after a registration is completed and stored. + +#### Scenario: Confirmation sent to parent email +- **WHEN** a registration is completed +- **THEN** the system SHALL send a confirmation email to the address in `parentGuardian.email` + +#### Scenario: Confirmation sent before or alongside admin notification +- **WHEN** a registration is completed +- **THEN** both the admin notification and the parent confirmation SHALL be dispatched in the same completion event; failure of either SHALL be logged but SHALL NOT block the other or fail the registration + +#### Scenario: No confirmation for incomplete registration +- **WHEN** a registration is not yet complete (any required field missing) +- **THEN** no confirmation email SHALL be sent to the parent + +--- + +### Requirement: Confirmation email contains full registration summary +The confirmation email SHALL include a summary of all registration data the parent submitted. + +#### Scenario: All required fields present in confirmation +- **WHEN** the confirmation email is sent +- **THEN** it SHALL include child name, date of birth, special needs, selected playgroup type(s), selected days, parent/guardian contact details, and emergency contact + +#### Scenario: Monthly fee shown as informational text +- **WHEN** the confirmation email is sent +- **THEN** it SHALL display the calculated monthly subscription fee as informational text (not a payment request) + +--- + +### Requirement: Confirmation email contains payment instructions for registration fee +The confirmation email SHALL include instructions for paying the one-time CHF 80 registration fee. + +#### Scenario: IBAN and payee shown as text +- **WHEN** the confirmation email is sent +- **THEN** it SHALL display the payee name, IBAN, and amount in plain text so the parent can pay manually if the QR code is not rendered + +#### Scenario: Swiss QR-bill embedded inline +- **WHEN** the confirmation email is sent +- **THEN** it SHALL include a Swiss QR-bill image (per SIX Group standard) embedded inline as a `Content-ID` referenced image within the HTML part +- **AND** the QR-bill SHALL encode: IBAN `CH14 0900 0000 4930 8018 8`, payee Familienverein Fällanden Spielgruppen (Huebwisstrase 5, 8117 Fällanden), amount CHF 80.00, currency CHF, reference type NON + +#### Scenario: QR-bill fallback for non-HTML clients +- **WHEN** a parent's email client does not render HTML +- **THEN** the plain-text part SHALL include the IBAN and payee details in full so payment is still possible without the QR code + +--- + +### Requirement: Confirmation email language matches parent's detected language +The confirmation email body SHALL be rendered in the language detected during the conversation. + +#### Scenario: German parent receives German confirmation +- **WHEN** the conversation language is `"de"` +- **THEN** the confirmation email body SHALL be in German + +#### Scenario: English-speaking parent receives English confirmation +- **WHEN** the conversation language is `"en"` +- **THEN** the confirmation email body SHALL be in English + +#### Scenario: Unknown language falls back to German +- **WHEN** the stored language value is not `"de"` or `"en"` +- **THEN** the confirmation email SHALL be sent in German + +#### Scenario: QR-bill slip labels are not translated +- **WHEN** the confirmation email is rendered in any language +- **THEN** the Swiss QR-bill payment slip labels SHALL remain in German (per SIX Group standard; the slip is internationally recognisable without translation) + +--- + +### Requirement: Parent's conversation language is persisted in the registration record +The language detected during the parent's conversation SHALL be stored in the completed registration record. + +#### Scenario: Language written to registration record +- **WHEN** a registration is stored +- **THEN** the JSON record SHALL include a `metadata.language` field containing the detected language code (`"de"` or `"en"`) + +#### Scenario: Language defaults to German when not detected +- **WHEN** no language was explicitly detected during the conversation +- **THEN** `metadata.language` SHALL be `"de"` diff --git a/openspec/changes/registration-confirmation-email/tasks.md b/openspec/changes/registration-confirmation-email/tasks.md new file mode 100644 index 0000000..73e6b86 --- /dev/null +++ b/openspec/changes/registration-confirmation-email/tasks.md @@ -0,0 +1,52 @@ +## 1. Add `qrbill` Dependency + +- [ ] 1.1 Add `qrbill` to `[project.dependencies]` in `pyproject.toml` +- [ ] 1.2 Run `uv lock` to update the lockfile +- [ ] 1.3 Verify `qrbill` imports successfully in a smoke test or REPL + +## 2. Persist Language in Registration Record + +- [ ] 2.1 Update `ConversationStore._build_record()` in `src/storage/json_store.py` to include `language` from `state.language` in the `metadata` dict +- [ ] 2.2 Update `ConversationStore.save_registration()` and `save_registration_version()` signatures to accept/forward `state` (already does — confirm `_build_record` receives the full state) +- [ ] 2.3 Add a test in `tests/test_storage.py` asserting that the saved record's `metadata.language` matches `state.language` + +## 3. Add `notify_parent()` to `AdminNotifier` + +- [ ] 3.1 Add a `_generate_qr_bill_png()` static/class method to `AdminNotifier` using `qrbill` with fixed payment data: + - IBAN: `CH14 0900 0000 4930 8018 8` + - Payee: Familienverein Fällanden Spielgruppen, Huebwisstrase 5, 8117 Fällanden + - Amount: `80.00`, Currency: `CHF`, Reference type: NON + - Returns raw PNG `bytes` +- [ ] 3.2 Add bilingual string template dicts `_STRINGS_DE` and `_STRINGS_EN` (module-level constants) covering all user-visible strings in the confirmation email (subject, section headers, fee labels, payment instructions text, closing) +- [ ] 3.3 Add `_build_parent_html()` method: renders full HTML confirmation email body using the appropriate string dict, embedding the QR image via `cid:qrbill`; includes registration summary and both monthly fee (informational) and CHF 80 registration fee (with IBAN text + QR reference) +- [ ] 3.4 Add `_build_parent_text()` method: renders the plain-text fallback, including all summary fields and IBAN/payee details in plain text (no image) +- [ ] 3.5 Add `notify_parent()` public method: + - Parameters: `registration: RegistrationData`, `language: str = "de"` + - Select string dict based on `language`; fall back to `"de"` for unknown values + - Call `_generate_qr_bill_png()` to get PNG bytes + - Build MIME structure: `multipart/mixed` > `multipart/alternative` > plain text part + `multipart/related` > HTML part + inline PNG (`Content-Disposition: inline`, `Content-ID: `) + - Call `_send()` with `to=[registration.parent_guardian.email]`, empty `cc`, localised subject, the assembled MIME message + - If `_smtp_host` is empty (dev mode), log and skip as with `notify_admin` + +## 4. Wire `notify_parent()` into Completion Events + +- [ ] 4.1 In `src/agent/core.py` `_handle_registration()`: after the existing `notify_admin()` try/except block, add a parallel try/except block calling `self._notifier.notify_parent(registration=state.registration, language=state.language)` +- [ ] 4.2 In `chat_app.py` `on_message()`: after the existing `notify_admin()` call inside the completion block, add a parallel try/except block calling `_notifier.notify_parent(registration=state.registration, language=state.language)` +- [ ] 4.3 Verify both call sites log a warning (not an exception) on failure, and the registration completion path continues normally + +## 5. Tests + +- [ ] 5.1 Add `tests/test_notifier.py` tests for `notify_parent()`: + - `test_notify_parent_calls_send`: mock `_send` and assert it is called with `to=[parent_email]` + - `test_notify_parent_german_subject`: assert subject contains German text when `language="de"` + - `test_notify_parent_english_subject`: assert subject contains English text when `language="en"` + - `test_notify_parent_unknown_language_falls_back_to_de`: assert `language="fr"` produces German subject + - `test_notify_parent_no_smtp_skips_send`: when `smtp_host=""`, `_send` is NOT called +- [ ] 5.2 Add a test asserting that the plain-text body contains the IBAN string `CH14` when `smtp_host` is empty (inspecting log or body build directly) +- [ ] 5.3 Add a test for `_generate_qr_bill_png()` asserting it returns `bytes` with non-zero length (requires `qrbill` installed) + +## 6. Manual Smoke Test + +- [ ] 6.1 Run `chainlit run chat_app.py` locally (or the email poller), complete a registration end-to-end, and verify the parent confirmation email arrives with the inline QR image rendered correctly +- [ ] 6.2 Verify the admin notification still arrives unchanged alongside the parent confirmation +- [ ] 6.3 Verify the saved `current.json` for the registration includes `metadata.language` From b2e04fa07b43b2b881fb5520bb345c2979af245a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:23:32 +0000 Subject: [PATCH 06/12] implement registration-confirmation-email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add qrbill and pillow dependencies for Swiss QR-bill PNG generation - Add _STRINGS_DE / _STRINGS_EN bilingual string tables to AdminNotifier - Add _generate_qr_bill_png(): Swiss QR code with cross overlay (PNG bytes) - Add _build_parent_html(): HTML confirmation with inline cid:qrbill image - Add _build_parent_text(): plain-text fallback with IBAN in full - Add notify_parent(): multipart/mixed MIME email to parent on completion - Persist metadata.language in _build_record() in json_store.py - Wire notify_parent() into src/agent/core.py and chat_app.py completion events - Add 9 tests for notify_parent, _generate_qr_bill_png, language fallback - Update tasks.md: tasks 1–5 complete; task 6 (smoke test) remains manual 112 tests passing. https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- chat_app.py | 9 + .../registration-confirmation-email/tasks.md | 34 +- pyproject.toml | 2 + src/agent/core.py | 7 + src/notifications/notifier.py | 457 +++++++++++++++++- src/storage/json_store.py | 1 + tests/test_notifier.py | 132 ++++- tests/test_storage.py | 17 + uv.lock | 116 +++++ 9 files changed, 756 insertions(+), 19 deletions(-) diff --git a/chat_app.py b/chat_app.py index 18c67ab..6026ccb 100644 --- a/chat_app.py +++ b/chat_app.py @@ -172,6 +172,15 @@ async def on_message(message: cl.Message) -> None: logger.exception( "Failed to save/notify for session %s", state.conversation_id ) + try: + _notifier.notify_parent( + registration=state.registration, + language=state.language, + ) + except Exception: + logger.exception( + "Failed to send parent confirmation for session %s", state.conversation_id + ) # --- Handle post-completion update intent --- if state.completed and intent == "update" and any(v is not None for v in updates.values()): diff --git a/openspec/changes/registration-confirmation-email/tasks.md b/openspec/changes/registration-confirmation-email/tasks.md index 73e6b86..39f8a3d 100644 --- a/openspec/changes/registration-confirmation-email/tasks.md +++ b/openspec/changes/registration-confirmation-email/tasks.md @@ -1,26 +1,26 @@ ## 1. Add `qrbill` Dependency -- [ ] 1.1 Add `qrbill` to `[project.dependencies]` in `pyproject.toml` -- [ ] 1.2 Run `uv lock` to update the lockfile -- [ ] 1.3 Verify `qrbill` imports successfully in a smoke test or REPL +- [x] 1.1 Add `qrbill` to `[project.dependencies]` in `pyproject.toml` +- [x] 1.2 Run `uv lock` to update the lockfile +- [x] 1.3 Verify `qrbill` imports successfully in a smoke test or REPL ## 2. Persist Language in Registration Record -- [ ] 2.1 Update `ConversationStore._build_record()` in `src/storage/json_store.py` to include `language` from `state.language` in the `metadata` dict -- [ ] 2.2 Update `ConversationStore.save_registration()` and `save_registration_version()` signatures to accept/forward `state` (already does — confirm `_build_record` receives the full state) -- [ ] 2.3 Add a test in `tests/test_storage.py` asserting that the saved record's `metadata.language` matches `state.language` +- [x] 2.1 Update `ConversationStore._build_record()` in `src/storage/json_store.py` to include `language` from `state.language` in the `metadata` dict +- [x] 2.2 Update `ConversationStore.save_registration()` and `save_registration_version()` signatures to accept/forward `state` (already does — confirm `_build_record` receives the full state) +- [x] 2.3 Add a test in `tests/test_storage.py` asserting that the saved record's `metadata.language` matches `state.language` ## 3. Add `notify_parent()` to `AdminNotifier` -- [ ] 3.1 Add a `_generate_qr_bill_png()` static/class method to `AdminNotifier` using `qrbill` with fixed payment data: +- [x] 3.1 Add a `_generate_qr_bill_png()` static/class method to `AdminNotifier` using `qrbill` with fixed payment data: - IBAN: `CH14 0900 0000 4930 8018 8` - Payee: Familienverein Fällanden Spielgruppen, Huebwisstrase 5, 8117 Fällanden - Amount: `80.00`, Currency: `CHF`, Reference type: NON - Returns raw PNG `bytes` -- [ ] 3.2 Add bilingual string template dicts `_STRINGS_DE` and `_STRINGS_EN` (module-level constants) covering all user-visible strings in the confirmation email (subject, section headers, fee labels, payment instructions text, closing) -- [ ] 3.3 Add `_build_parent_html()` method: renders full HTML confirmation email body using the appropriate string dict, embedding the QR image via `cid:qrbill`; includes registration summary and both monthly fee (informational) and CHF 80 registration fee (with IBAN text + QR reference) -- [ ] 3.4 Add `_build_parent_text()` method: renders the plain-text fallback, including all summary fields and IBAN/payee details in plain text (no image) -- [ ] 3.5 Add `notify_parent()` public method: +- [x] 3.2 Add bilingual string template dicts `_STRINGS_DE` and `_STRINGS_EN` (module-level constants) covering all user-visible strings in the confirmation email (subject, section headers, fee labels, payment instructions text, closing) +- [x] 3.3 Add `_build_parent_html()` method: renders full HTML confirmation email body using the appropriate string dict, embedding the QR image via `cid:qrbill`; includes registration summary and both monthly fee (informational) and CHF 80 registration fee (with IBAN text + QR reference) +- [x] 3.4 Add `_build_parent_text()` method: renders the plain-text fallback, including all summary fields and IBAN/payee details in plain text (no image) +- [x] 3.5 Add `notify_parent()` public method: - Parameters: `registration: RegistrationData`, `language: str = "de"` - Select string dict based on `language`; fall back to `"de"` for unknown values - Call `_generate_qr_bill_png()` to get PNG bytes @@ -30,20 +30,20 @@ ## 4. Wire `notify_parent()` into Completion Events -- [ ] 4.1 In `src/agent/core.py` `_handle_registration()`: after the existing `notify_admin()` try/except block, add a parallel try/except block calling `self._notifier.notify_parent(registration=state.registration, language=state.language)` -- [ ] 4.2 In `chat_app.py` `on_message()`: after the existing `notify_admin()` call inside the completion block, add a parallel try/except block calling `_notifier.notify_parent(registration=state.registration, language=state.language)` -- [ ] 4.3 Verify both call sites log a warning (not an exception) on failure, and the registration completion path continues normally +- [x] 4.1 In `src/agent/core.py` `_handle_registration()`: after the existing `notify_admin()` try/except block, add a parallel try/except block calling `self._notifier.notify_parent(registration=state.registration, language=state.language)` +- [x] 4.2 In `chat_app.py` `on_message()`: after the existing `notify_admin()` call inside the completion block, add a parallel try/except block calling `_notifier.notify_parent(registration=state.registration, language=state.language)` +- [x] 4.3 Verify both call sites log a warning (not an exception) on failure, and the registration completion path continues normally ## 5. Tests -- [ ] 5.1 Add `tests/test_notifier.py` tests for `notify_parent()`: +- [x] 5.1 Add `tests/test_notifier.py` tests for `notify_parent()`: - `test_notify_parent_calls_send`: mock `_send` and assert it is called with `to=[parent_email]` - `test_notify_parent_german_subject`: assert subject contains German text when `language="de"` - `test_notify_parent_english_subject`: assert subject contains English text when `language="en"` - `test_notify_parent_unknown_language_falls_back_to_de`: assert `language="fr"` produces German subject - `test_notify_parent_no_smtp_skips_send`: when `smtp_host=""`, `_send` is NOT called -- [ ] 5.2 Add a test asserting that the plain-text body contains the IBAN string `CH14` when `smtp_host` is empty (inspecting log or body build directly) -- [ ] 5.3 Add a test for `_generate_qr_bill_png()` asserting it returns `bytes` with non-zero length (requires `qrbill` installed) +- [x] 5.2 Add a test asserting that the plain-text body contains the IBAN string `CH14` when `smtp_host` is empty (inspecting log or body build directly) +- [x] 5.3 Add a test for `_generate_qr_bill_png()` asserting it returns `bytes` with non-zero length (requires `qrbill` installed) ## 6. Manual Smoke Test diff --git a/pyproject.toml b/pyproject.toml index 5063f08..57feb3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ # Registration schema validation "jsonschema>=4.23.0", "chainlit>=2.9.6", + "qrbill>=1.2.0", + "pillow>=12.1.1", ] [project.scripts] diff --git a/src/agent/core.py b/src/agent/core.py index 403faa1..91cad5f 100644 --- a/src/agent/core.py +++ b/src/agent/core.py @@ -129,6 +129,13 @@ class EmailAgent: ) except Exception: logger.exception("Failed to send admin notification for %s", email_key) + try: + self._notifier.notify_parent( + registration=state.registration, + language=state.language, + ) + except Exception: + logger.exception("Failed to send parent confirmation for %s", email_key) logger.info("Registration complete for %s", state.conversation_id) return reply_text diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py index a75ffeb..34b11db 100644 --- a/src/notifications/notifier.py +++ b/src/notifications/notifier.py @@ -1,16 +1,149 @@ -"""Admin email notifications — new registrations and registration updates.""" +"""Admin email notifications — new registrations, updates, and parent confirmations.""" +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 +import qrcode +import qrcode.constants +from PIL import Image, ImageDraw +from qrbill import QRBill + from ..models.registration import RegistrationData logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Bilingual string tables for parent confirmation emails +# --------------------------------------------------------------------------- + +_STRINGS_DE: dict = { + "subject": "Anmeldebestätigung – Spielgruppe Pumuckl", + "greeting": "Guten Tag {name}", + "intro": ( + "Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen. " + "Hier ist eine Zusammenfassung:" + ), + "child_section": "Angaben zum Kind", + "child_name": "Name", + "child_dob": "Geburtsdatum", + "child_needs": "Besondere Bedürfnisse", + "booking_section": "Spielgruppen-Buchung", + "booking_type": "Art", + "booking_days": "Tage", + "fees_section": "Kosten", + "monthly_fee": "Monatlicher Beitrag", + "reg_fee": "Anmeldegebühr (einmalig, erstes Jahr)", + "deposit": "Reinigungsdepot Innen (rückerstattbar)", + "parent_section": "Deine Kontaktdaten", + "parent_name": "Name", + "parent_address": "Adresse", + "parent_phone": "Telefon", + "parent_email": "E-Mail", + "emergency_section": "Notfallkontakt", + "emergency_name": "Name", + "emergency_phone": "Telefon", + "payment_section": "Zahlungsinformationen", + "payment_intro": ( + "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto. " + "Du kannst den QR-Code mit deiner Banking-App scannen:" + ), + "payment_intro_text": ( + "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:" + ), + "iban_label": "IBAN", + "payee_label": "Empfänger", + "amount_label": "Betrag", + "closing": ( + "Bei Fragen stehen wir dir gerne zur Verfügung. " + "Wir freuen uns auf dein Kind!\n\n" + "Herzliche Grüsse\n" + "Spielgruppe Pumuckl" + ), + "days": { + "monday": "Montag", + "wednesday": "Mittwoch", + "thursday": "Donnerstag", + }, + "types": { + "indoor": "Innenspielgruppe", + "outdoor": "Waldspielgruppe", + }, + "none": "Keine", + "deposit_amount": "CHF 50.00", + "reg_fee_amount": "CHF 80.00", +} + +_STRINGS_EN: dict = { + "subject": "Registration Confirmation – Spielgruppe Pumuckl", + "greeting": "Dear {name}", + "intro": ( + "Your registration with Spielgruppe Pumuckl has been received. " + "Here is a summary:" + ), + "child_section": "Child Details", + "child_name": "Name", + "child_dob": "Date of birth", + "child_needs": "Special needs", + "booking_section": "Playgroup Booking", + "booking_type": "Type", + "booking_days": "Days", + "fees_section": "Fees", + "monthly_fee": "Monthly subscription", + "reg_fee": "Registration fee (one-time, first year)", + "deposit": "Cleaning deposit – indoor (refundable)", + "parent_section": "Your Contact Details", + "parent_name": "Name", + "parent_address": "Address", + "parent_phone": "Phone", + "parent_email": "Email", + "emergency_section": "Emergency Contact", + "emergency_name": "Name", + "emergency_phone": "Phone", + "payment_section": "Payment Details", + "payment_intro": ( + "Please transfer the registration fee of CHF 80.00 to the account below. " + "You can scan the QR code with your banking app:" + ), + "payment_intro_text": ( + "Please transfer the registration fee of CHF 80.00 to the following account:" + ), + "iban_label": "IBAN", + "payee_label": "Payee", + "amount_label": "Amount", + "closing": ( + "If you have any questions, we are happy to help. " + "We look forward to welcoming your child!\n\n" + "Kind regards\n" + "Spielgruppe Pumuckl" + ), + "days": { + "monday": "Monday", + "wednesday": "Wednesday", + "thursday": "Thursday", + }, + "types": { + "indoor": "Indoor Playgroup", + "outdoor": "Forest Playgroup", + }, + "none": "None", + "deposit_amount": "CHF 50.00", + "reg_fee_amount": "CHF 80.00", +} + +# Fixed Swiss QR-bill payment data (stable bank details — not in config) +_QR_IBAN = "CH14 0900 0000 4930 8018 8" +_QR_PAYEE = "Familienverein Fällanden Spielgruppen" +_QR_STREET = "Huebwisstrase 5" +_QR_PCODE = "8117" +_QR_CITY = "Fällanden" + + class AdminNotifier: """Sends formatted admin notification emails. @@ -341,3 +474,325 @@ class AdminNotifier: 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, + language: str = "de", + ) -> None: + """Send an HTML confirmation email to the parent with registration summary and QR-bill.""" + parent_email = registration.parent_guardian.email + if not parent_email: + logger.warning("No parent email in registration — confirmation not sent.") + return + + strings = _STRINGS_EN if language == "en" else _STRINGS_DE + + try: + qr_png = self._generate_qr_bill_png() + except Exception: + 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) + subject = strings["subject"] + + if not self._smtp_host: + logger.warning( + "SMTP not configured — parent confirmation NOT sent. Would have emailed %s: %s", + parent_email, + subject, + ) + logger.debug("Parent confirmation body:\n%s", text_body) + return + + # MIME structure: + # multipart/mixed + # └── multipart/alternative + # ├── text/plain (fallback) + # └── multipart/related + # ├── text/html (references cid:qrbill) + # └── image/png (Content-ID: qrbill, inline) + msg_outer = MIMEMultipart("mixed") + msg_outer["From"] = self._from_email + msg_outer["To"] = parent_email + msg_outer["Subject"] = subject + + msg_alt = MIMEMultipart("alternative") + msg_alt.attach(MIMEText(text_body, "plain", "utf-8")) + + if qr_png is not None: + msg_related = MIMEMultipart("related") + msg_related.attach(MIMEText(html_body, "html", "utf-8")) + img_part = MIMEImage(qr_png, "png") + img_part.add_header("Content-ID", "") + img_part.add_header("Content-Disposition", "inline", filename="qrbill.png") + msg_related.attach(img_part) + msg_alt.attach(msg_related) + else: + msg_alt.attach(MIMEText(html_body, "html", "utf-8")) + + msg_outer.attach(msg_alt) + + 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, [parent_email], msg_outer.as_string()) + server.quit() + logger.info("Parent confirmation sent to %s", parent_email) + except Exception: + logger.exception("Failed to send parent confirmation to %s", parent_email) + + @staticmethod + def _generate_qr_bill_png() -> bytes: + """Generate a Swiss QR-bill payment QR code as a PNG image. + + Uses the fixed registration fee payment data (CHF 80.00). + The QR code includes the Swiss cross overlay as required by the SIX Group standard. + + Returns: + PNG image bytes of the QR code. + """ + bill = QRBill( + account=_QR_IBAN, + creditor={ + "name": _QR_PAYEE, + "street": _QR_STREET, + "pcode": _QR_PCODE, + "city": _QR_CITY, + "country": "CH", + }, + amount="80.00", + currency="CHF", + ) + payload = bill.qr_data() + + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=8, + border=4, + ) + qr.add_data(payload) + qr.make(fit=True) + pil_img: Image.Image = qr.make_image(fill_color="black", back_color="white").get_image() + pil_img = pil_img.convert("RGB") + + # Overlay Swiss cross in center (SIX Group standard) + w, h = pil_img.size + cross_size = max(int(w * 0.15), 20) + cx, cy = w // 2, h // 2 + half = cross_size // 2 + bar = cross_size // 5 + draw = ImageDraw.Draw(pil_img) + draw.rectangle([cx - half, cy - half, cx + half, cy + half], fill="white") + draw.rectangle([cx - bar // 2, cy - half, cx + bar // 2, cy + half], fill="#FF0000") + draw.rectangle([cx - half, cy - bar // 2, cx + half, cy + bar // 2], fill="#FF0000") + + buf = io.BytesIO() + pil_img.save(buf, format="PNG") + return buf.getvalue() + + def _build_parent_html( + self, + registration: RegistrationData, + strings: dict, + has_qr: bool = True, + ) -> str: + """Render the HTML body for the parent confirmation email.""" + pg = registration.parent_guardian + ec = registration.emergency_contact + ch = registration.child + + parent_name = pg.full_name or pg.email or "" + greeting = strings["greeting"].format(name=parent_name) + dob_display = self._format_dob(ch.date_of_birth or "") + special_needs = ch.special_needs or strings["none"] + pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings) + pg_days = self._format_days_bilingual(registration, strings) + monthly_fee = self._calculate_monthly_fee(registration) + has_indoor = "indoor" in registration.booking.playgroup_types + + qr_section = "" + if has_qr: + qr_section = ( + '
' + 'Swiss QR-Bill' + "
" + ) + + deposit_row = "" + if has_indoor: + deposit_row = ( + f"{strings['deposit']}" + f"{strings['deposit_amount']}" + ) + + return f""" + + + + +
+

Spielgruppe Pumuckl

+

Familienverein Fällanden

+
+ +
+ +

{greeting},

+

{strings['intro']}

+ +

{strings['child_section']}

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

{strings['booking_section']}

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

{strings['fees_section']}

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

{strings['payment_section']}

+

{strings['payment_intro']}

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

{strings['parent_section']}

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

{strings['emergency_section']}

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

{strings['closing']}

+ +
+ +""" + + def _build_parent_text(self, registration: RegistrationData, strings: dict) -> str: + """Render the plain-text fallback for the parent confirmation email.""" + pg = registration.parent_guardian + ec = registration.emergency_contact + ch = registration.child + + parent_name = pg.full_name or pg.email or "" + greeting = strings["greeting"].format(name=parent_name) + dob_display = self._format_dob(ch.date_of_birth or "") + special_needs = ch.special_needs or strings["none"] + pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings) + pg_days = self._format_days_bilingual(registration, strings) + monthly_fee = self._calculate_monthly_fee(registration) + has_indoor = "indoor" in registration.booking.playgroup_types + + deposit_line = "" + if has_indoor: + deposit_line = f"{strings['deposit']}: {strings['deposit_amount']}\n" + + return ( + f"{greeting},\n\n" + f"{strings['intro']}\n\n" + "===============================================\n" + f"{strings['child_section'].upper()}\n" + "===============================================\n" + f"{strings['child_name']}: {ch.full_name or ''}\n" + f"{strings['child_dob']}: {dob_display}\n" + f"{strings['child_needs']}: {special_needs}\n" + "\n" + "===============================================\n" + f"{strings['booking_section'].upper()}\n" + "===============================================\n" + f"{strings['booking_type']}: {pg_types}\n" + f"{strings['booking_days']}: {pg_days}\n" + "\n" + "===============================================\n" + f"{strings['fees_section'].upper()}\n" + "===============================================\n" + f"{strings['monthly_fee']}: {monthly_fee}\n" + f"{strings['reg_fee']}: {strings['reg_fee_amount']}\n" + f"{deposit_line}" + "\n" + "===============================================\n" + f"{strings['payment_section'].upper()}\n" + "===============================================\n" + f"{strings['payment_intro_text']}\n\n" + f" {strings['iban_label']}: CH14 0900 0000 4930 8018 8\n" + f" {strings['payee_label']}: Familienverein Fällanden Spielgruppen\n" + f" Huebwisstrase 5, 8117 Fällanden\n" + f" {strings['amount_label']}: CHF 80.00\n" + "\n" + "===============================================\n" + f"{strings['parent_section'].upper()}\n" + "===============================================\n" + f"{strings['parent_name']}: {pg.full_name or ''}\n" + f"{strings['parent_address']}: {pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}\n" + f"{strings['parent_phone']}: {pg.phone or ''}\n" + f"{strings['parent_email']}: {pg.email or ''}\n" + "\n" + "===============================================\n" + f"{strings['emergency_section'].upper()}\n" + "===============================================\n" + f"{strings['emergency_name']}: {ec.full_name or ''}\n" + f"{strings['emergency_phone']}: {ec.phone or ''}\n" + "\n" + "-----------------------------------------------\n\n" + f"{strings['closing']}\n" + ) + + @staticmethod + def _format_types_bilingual(types: list[str], strings: dict) -> str: + """Format playgroup types using the language-specific type map.""" + type_map: dict = strings["types"] + labels = [type_map.get(t, t) for t in types] + return ", ".join(labels) if labels else "" + + @staticmethod + def _format_days_bilingual(registration: RegistrationData, strings: dict) -> str: + """Format selected days using the language-specific day map.""" + day_map: dict = strings["days"] + type_map: dict = strings["types"] + return ", ".join( + f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})" + for d in registration.booking.selected_days + ) diff --git a/src/storage/json_store.py b/src/storage/json_store.py index 5012305..d4b3b8c 100644 --- a/src/storage/json_store.py +++ b/src/storage/json_store.py @@ -254,6 +254,7 @@ class ConversationStore: "channel": "email", "parentEmail": state.parent_email, "conversationId": state.conversation_id, + "language": state.language, } return record diff --git a/tests/test_notifier.py b/tests/test_notifier.py index f84ca9d..abb08d1 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -1,11 +1,25 @@ """Tests for AdminNotifier helper methods.""" +import email +from email.header import decode_header + import pytest -from src.notifications.notifier import AdminNotifier +from src.notifications.notifier import AdminNotifier, _STRINGS_DE, _STRINGS_EN from src.models.registration import RegistrationData, Booking, BookingDay +def _decoded_subject(msg_str: str) -> str: + """Parse a raw MIME message string and return the decoded Subject header.""" + msg = email.message_from_string(msg_str) + raw_subject = msg.get("Subject", "") + parts = decode_header(raw_subject) + return "".join( + chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk + for chunk, enc in parts + ) + + @pytest.fixture def notifier(): return AdminNotifier( @@ -135,3 +149,119 @@ class TestSend: recipients = call_args[0][1] # positional arg: to_addrs assert "a@example.com" in recipients assert "b@example.com" in recipients + + +# --------------------------------------------------------------------------- +# notify_parent — parent confirmation email +# --------------------------------------------------------------------------- + + +@pytest.fixture +def notifier_no_smtp(): + """Notifier in dev mode (no SMTP host).""" + return AdminNotifier( + smtp_host="", + smtp_port=587, + username="", + password="", + from_email="agent@example.com", + ) + + +class TestNotifyParent: + def test_notify_parent_calls_send(self, notifier, complete_registration, mocker): + """notify_parent dispatches an email to the parent address.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + mock_server = mock_smtp_cls.return_value + + notifier.notify_parent(complete_registration, language="de") + + mock_server.sendmail.assert_called_once() + call_args = mock_server.sendmail.call_args + recipients = call_args[0][1] + assert "anna.muster@example.com" in recipients + + def test_notify_parent_german_subject(self, notifier, complete_registration, mocker): + """German language produces a German subject line.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_parent(complete_registration, language="de") + + assert "Anmeldebestätigung" in _decoded_subject(captured["msg"]) + + def test_notify_parent_english_subject(self, notifier, complete_registration, mocker): + """English language produces an English subject line.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_parent(complete_registration, language="en") + + assert "Registration Confirmation" in _decoded_subject(captured["msg"]) + + def test_notify_parent_unknown_language_falls_back_to_de( + self, notifier, complete_registration, mocker + ): + """Unsupported language codes fall back to German.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_parent(complete_registration, language="fr") + + assert "Anmeldebestätigung" in _decoded_subject(captured["msg"]) + + def test_notify_parent_no_smtp_skips_send( + self, notifier_no_smtp, complete_registration, mocker + ): + """When SMTP host is empty, no sendmail call is made.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + + notifier_no_smtp.notify_parent(complete_registration, language="de") + + mock_smtp_cls.assert_not_called() + + def test_notify_parent_text_body_contains_iban(self, notifier_no_smtp, 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) + assert "CH14" in text + + def test_notify_parent_text_body_english_contains_iban( + self, notifier_no_smtp, complete_registration + ): + """English plain-text body also includes the IBAN.""" + text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_EN) + assert "CH14" in text + + +# --------------------------------------------------------------------------- +# _generate_qr_bill_png +# --------------------------------------------------------------------------- + + +class TestGenerateQrBillPng: + def test_returns_nonempty_bytes(self, notifier): + """_generate_qr_bill_png returns a non-empty bytes object (PNG).""" + png = notifier._generate_qr_bill_png() + assert isinstance(png, bytes) + assert len(png) > 0 + + 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" diff --git a/tests/test_storage.py b/tests/test_storage.py index a957826..4ce3a64 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -157,3 +157,20 @@ class TestRegistrationVersioning: store.save_registration(fresh_state) registrations = store.list_registrations() assert len(registrations) == 1 + + def test_save_registration_persists_language(self, store, fresh_state, complete_registration): + fresh_state.registration = complete_registration + fresh_state.completed = True + fresh_state.language = "en" + store.save_registration(fresh_state) + current = store.get_current_registration(fresh_state.parent_email) + assert current is not None + assert current["metadata"]["language"] == "en" + + def test_save_registration_defaults_language_to_de(self, store, fresh_state, complete_registration): + fresh_state.registration = complete_registration + fresh_state.completed = True + # language defaults to "de" in ConversationState + store.save_registration(fresh_state) + current = store.get_current_registration(fresh_state.parent_email) + assert current["metadata"]["language"] == "de" diff --git a/uv.lock b/uv.lock index 7c79a34..c05631d 100644 --- a/uv.lock +++ b/uv.lock @@ -823,6 +823,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "iso3166" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/11/b5023c736a185a88ebd0d38646af6f4d1b4c9b91f2ca84e08e5d2bc7ac3c/iso3166-2.1.1.tar.gz", hash = "sha256:fcd551b8dda66b44e9f9e6d6bbbee3a1145a22447c0a556e5d0fb1ad1e491719", size = 12807, upload-time = "2022-07-12T04:07:57.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/d0/bf18725b8d47f37858ff801f8e4d40c6982730a899725bdb6ded62199954/iso3166-2.1.1-py3-none-any.whl", hash = "sha256:263660b36f8471c42acd1ff673d28a3715edbce7d24b1550d0cf010f6816c47f", size = 9829, upload-time = "2022-07-12T04:07:55.54Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1076,7 +1085,9 @@ dependencies = [ { name = "chainlit" }, { name = "jsonschema" }, { name = "litellm" }, + { name = "pillow" }, { name = "python-dotenv" }, + { name = "qrbill" }, ] [package.dev-dependencies] @@ -1091,7 +1102,9 @@ requires-dist = [ { name = "chainlit", specifier = ">=2.9.6" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "litellm", specifier = ">=1.0.0" }, + { name = "pillow", specifier = ">=12.1.1" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "qrbill", specifier = ">=1.2.0" }, ] [package.metadata.requires-dev] @@ -1918,6 +1931,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2208,6 +2279,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, ] +[[package]] +name = "python-stdnum" +version = "2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/7f/96c2b9de6024353177dc6139c33730d5ac25877bc33215515d6b95b84555/python_stdnum-2.2.tar.gz", hash = "sha256:e95fcfa858a703d4a40130cb3eaac133c60d8808a7f3c98efeedac968c2479b9", size = 1311813, upload-time = "2026-01-04T19:36:16.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/61/aa32d9c79f83a2fae033cd6496fb2a24aba918d31c73704271dfcfb48375/python_stdnum-2.2-py3-none-any.whl", hash = "sha256:bdf98fd117a0ca152e4047aa8ad254bae63853d4e915ddd4e0effb33ba0e9260", size = 1193213, upload-time = "2026-01-04T19:36:14.812Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -2257,6 +2337,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrbill" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso3166" }, + { name = "python-stdnum" }, + { name = "qrcode" }, + { name = "svgwrite" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/38/95a9069070161becc3a7018fc5ee4edbfb973a012b31d4801d20eb30e1d8/qrbill-1.2.0.tar.gz", hash = "sha256:7a2e37940731890fea0f005189464ef5448382fbbee5e1e96bc2ddff86fc8bbe", size = 22357, upload-time = "2025-11-05T07:35:34.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/85/1660c9413411248cb30044bf4e2221319a23cd35f1609233d58fb1321708/qrbill-1.2.0-py3-none-any.whl", hash = "sha256:6a20c08c2bf5cd801408253e3d9292668bfbbd4ed50de071fa80a8922f9a41ca", size = 13201, upload-time = "2025-11-05T07:35:33.323Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2491,6 +2598,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "svgwrite" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/c1/263d4e93b543390d86d8eb4fc23d9ce8a8d6efd146f9427364109004fa9b/svgwrite-1.4.3.zip", hash = "sha256:a8fbdfd4443302a6619a7f76bc937fc683daf2628d9b737c891ec08b8ce524c3", size = 189516, upload-time = "2022-07-14T14:05:26.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/15/640e399579024a6875918839454025bb1d5f850bb70d96a11eabb644d11c/svgwrite-1.4.3-py3-none-any.whl", hash = "sha256:bb6b2b5450f1edbfa597d924f9ac2dd099e625562e492021d7dd614f65f8a22d", size = 67122, upload-time = "2022-07-14T14:05:24.459Z" }, +] + [[package]] name = "syncer" version = "2.0.3" From 54d88d1d648a8a245617923ae10d4a9da4314898 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 10:05:03 +0000 Subject: [PATCH 07/12] refactor notifications to MVC: extract templates, context, and renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move all email layout to Jinja2 templates (4 files: admin_new, admin_update, parent_confirmation html+txt) - Extract pure helper/context-builder functions to context.py (format_types, calculate_age, format_dob, calculate_monthly_fee, format_days, build_admin_new_context, build_admin_update_context, build_parent_context) - Move i18n label strings out of Python into YAML locale files (i18n/de.yaml, i18n/en.yaml) — admin-editable without code changes - Add renderer.py as thin Jinja2 wrapper (render_template) - Reduce notifier.py to routing + SMTP dispatch only - Update tests to call context functions directly and render templates via renderer instead of calling private builder methods - Add jinja2 and pyyaml as explicit dependencies in pyproject.toml https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- pyproject.toml | 2 + src/notifications/context.py | 231 +++++++ src/notifications/i18n/de.yaml | 57 ++ src/notifications/i18n/en.yaml | 57 ++ src/notifications/notifier.py | 648 +++--------------- src/notifications/renderer.py | 18 + src/notifications/templates/admin_new.txt.j2 | 42 ++ .../templates/admin_update.txt.j2 | 33 + .../templates/parent_confirmation.html.j2 | 81 +++ .../templates/parent_confirmation.txt.j2 | 51 ++ tests/test_notifier.py | 75 +- 11 files changed, 699 insertions(+), 596 deletions(-) create mode 100644 src/notifications/context.py create mode 100644 src/notifications/i18n/de.yaml create mode 100644 src/notifications/i18n/en.yaml create mode 100644 src/notifications/renderer.py create mode 100644 src/notifications/templates/admin_new.txt.j2 create mode 100644 src/notifications/templates/admin_update.txt.j2 create mode 100644 src/notifications/templates/parent_confirmation.html.j2 create mode 100644 src/notifications/templates/parent_confirmation.txt.j2 diff --git a/pyproject.toml b/pyproject.toml index 57feb3d..f6f6140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "chainlit>=2.9.6", "qrbill>=1.2.0", "pillow>=12.1.1", + "jinja2>=3.0.0", + "pyyaml>=6.0.0", ] [project.scripts] diff --git a/src/notifications/context.py b/src/notifications/context.py new file mode 100644 index 0000000..19981d3 --- /dev/null +++ b/src/notifications/context.py @@ -0,0 +1,231 @@ +"""Pure functions for building email context dicts from registration data.""" + +from datetime import date, datetime +from pathlib import Path + +import yaml + +from ..models.registration import RegistrationData + +# --------------------------------------------------------------------------- +# Swiss QR-bill payment constants (stable bank details — not in config) +# --------------------------------------------------------------------------- + +QR_IBAN = "CH14 0900 0000 4930 8018 8" +QR_PAYEE = "Familienverein Fällanden Spielgruppen" +QR_STREET = "Huebwisstrase 5" +QR_PCODE = "8117" +QR_CITY = "Fällanden" + +_I18N_DIR = Path(__file__).parent / "i18n" + +# --------------------------------------------------------------------------- +# i18n +# --------------------------------------------------------------------------- + + +def load_strings(language: str) -> dict: + """Load the label/string table for *language* (falls back to German).""" + locale_file = _I18N_DIR / f"{language}.yaml" + if not locale_file.exists(): + locale_file = _I18N_DIR / "de.yaml" + with locale_file.open(encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +# --------------------------------------------------------------------------- +# Formatting helpers (pure functions, no side-effects) +# --------------------------------------------------------------------------- + + +def format_dob(dob_str: str) -> str: + """Return DD.MM.YYYY from a YYYY-MM-DD string, or the original on error.""" + try: + return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return dob_str or "" + + +def calculate_age(dob_str: str) -> str: + """Return 'X Jahre, Y Monate' from a YYYY-MM-DD string.""" + try: + dob = datetime.strptime(dob_str, "%Y-%m-%d").date() + today = date.today() + years = today.year - dob.year - ( + (today.month, today.day) < (dob.month, dob.day) + ) + months = (today.month - dob.month) % 12 + return f"{years} Jahre, {months} Monate" + except Exception: + return dob_str + + +def format_types(types: list[str]) -> str: + """German label for a list of playgroup type keys (admin emails).""" + has_indoor = "indoor" in types + has_outdoor = "outdoor" in types + if has_indoor and has_outdoor: + return "Innen- und Waldspielgruppe" + if has_indoor: + return "Innenspielgruppe" + if has_outdoor: + return "Waldspielgruppe" + return "Spielgruppe" + + +def format_types_i18n(types: list[str], strings: dict) -> str: + """Localised label for playgroup type keys using the supplied string table.""" + type_map: dict = strings["types"] + labels = [type_map.get(t, t) for t in types] + return ", ".join(labels) if labels else "" + + +def format_days(registration: RegistrationData) -> str: + """German day + type labels for admin emails.""" + day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"} + type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"} + return ", ".join( + f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})" + for d in registration.booking.selected_days + ) + + +def format_days_i18n(registration: RegistrationData, strings: dict) -> str: + """Localised day + type labels using the supplied string table.""" + day_map: dict = strings["days"] + type_map: dict = strings["types"] + return ", ".join( + f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})" + for d in registration.booking.selected_days + ) + + +def calculate_monthly_fee(registration: RegistrationData) -> str: + """Compute the monthly fee string from the booking selection.""" + indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor") + outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor") + fee = 0 + if indoor_days == 1: + fee += 130 + elif indoor_days == 2: + fee += 260 + elif indoor_days >= 3: + fee += 390 + if outdoor_days >= 1: + fee += 250 + return f"CHF {fee}.-" + + +# --------------------------------------------------------------------------- +# Context builders +# --------------------------------------------------------------------------- + + +def build_admin_new_context( + registration: RegistrationData, + registration_id: str, + version: int, + channel: str, +) -> dict: + """Build the template context for the admin new-registration email.""" + now = datetime.utcnow() + pg = registration.parent_guardian + ec = registration.emergency_contact + ch = registration.child + channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title()) + + return { + "submitted_date": now.strftime("%d.%m.%Y"), + "submitted_time": now.strftime("%H:%M"), + "channel": channel_de, + "registration_id": registration_id, + "version": version, + "child_name": ch.full_name or "", + "child_dob": format_dob(ch.date_of_birth or ""), + "child_age": calculate_age(ch.date_of_birth or ""), + "child_needs": ch.special_needs or "Keine", + "playgroup_types": format_types(registration.booking.playgroup_types), + "days": format_days(registration), + "monthly_fee": calculate_monthly_fee(registration), + "parent_name": pg.full_name or "", + "parent_street": pg.street_address or "", + "parent_postal_code": pg.postal_code or "", + "parent_city": pg.city or "", + "parent_phone": pg.phone or "", + "parent_email": pg.email or "", + "emergency_name": ec.full_name or "", + "emergency_phone": ec.phone or "", + } + + +def build_admin_update_context( + registration: RegistrationData, + registration_id: str, + version: int, + change_summary: dict, +) -> dict: + """Build the template context for the admin registration-update email.""" + now = datetime.utcnow() + pg = registration.parent_guardian + + changes = [ + {"field": field_path, "old": values["old"], "new": values["new"]} + for field_path, values in sorted(change_summary.items()) + ] + + return { + "updated_date": now.strftime("%d.%m.%Y"), + "updated_time": now.strftime("%H:%M"), + "registration_id": registration_id, + "version": version, + "child_name": registration.child.full_name or "", + "parent_email": pg.email or "", + "changes": changes, + "playgroup_types": format_types(registration.booking.playgroup_types), + "days": format_days(registration), + "monthly_fee": calculate_monthly_fee(registration), + "parent_name": pg.full_name or "", + "parent_street": pg.street_address or "", + "parent_postal_code": pg.postal_code or "", + "parent_city": pg.city or "", + "parent_phone": pg.phone or "", + } + + +def build_parent_context( + registration: RegistrationData, + strings: dict, + has_qr: bool = True, +) -> dict: + """Build the template context for the parent confirmation email.""" + pg = registration.parent_guardian + ec = registration.emergency_contact + ch = registration.child + parent_name = pg.full_name or pg.email or "" + + return { + "lang": "de" if strings.get("none") == "Keine" else "en", + "strings": strings, + "greeting": strings["greeting"].format(name=parent_name), + "child_name": ch.full_name or "", + "child_dob": format_dob(ch.date_of_birth or ""), + "child_needs": ch.special_needs or strings["none"], + "playgroup_types": format_types_i18n(registration.booking.playgroup_types, strings), + "days": format_days_i18n(registration, strings), + "monthly_fee": calculate_monthly_fee(registration), + "has_indoor": "indoor" in registration.booking.playgroup_types, + "has_qr": has_qr, + "parent_name": pg.full_name or "", + "parent_address": pg.street_address or "", + "parent_postal_code": pg.postal_code or "", + "parent_city": pg.city or "", + "parent_phone": pg.phone or "", + "parent_email": pg.email or "", + "emergency_name": ec.full_name or "", + "emergency_phone": ec.phone or "", + "iban": QR_IBAN, + "payee": QR_PAYEE, + "payee_street": QR_STREET, + "payee_postal_code": QR_PCODE, + "payee_city": QR_CITY, + } diff --git a/src/notifications/i18n/de.yaml b/src/notifications/i18n/de.yaml new file mode 100644 index 0000000..18d8bb0 --- /dev/null +++ b/src/notifications/i18n/de.yaml @@ -0,0 +1,57 @@ +subject: "Anmeldebestätigung – Spielgruppe Pumuckl" +greeting: "Guten Tag {name}" +intro: >- + Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen. + Hier ist eine Zusammenfassung: + +child_section: "Angaben zum Kind" +child_name: "Name" +child_dob: "Geburtsdatum" +child_needs: "Besondere Bedürfnisse" + +booking_section: "Spielgruppen-Buchung" +booking_type: "Art" +booking_days: "Tage" + +fees_section: "Kosten" +monthly_fee: "Monatlicher Beitrag" +reg_fee: "Anmeldegebühr (einmalig, erstes Jahr)" +reg_fee_amount: "CHF 80.00" +deposit: "Reinigungsdepot Innen (rückerstattbar)" +deposit_amount: "CHF 50.00" + +parent_section: "Deine Kontaktdaten" +parent_name: "Name" +parent_address: "Adresse" +parent_phone: "Telefon" +parent_email: "E-Mail" + +emergency_section: "Notfallkontakt" +emergency_name: "Name" +emergency_phone: "Telefon" + +payment_section: "Zahlungsinformationen" +payment_intro: >- + Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto. + Du kannst den QR-Code mit deiner Banking-App scannen: +payment_intro_text: "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:" +iban_label: "IBAN" +payee_label: "Empfänger" +amount_label: "Betrag" + +closing: | + Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind! + + Herzliche Grüsse + Spielgruppe Pumuckl + +none: "Keine" + +days: + monday: "Montag" + wednesday: "Mittwoch" + thursday: "Donnerstag" + +types: + indoor: "Innenspielgruppe" + outdoor: "Waldspielgruppe" diff --git a/src/notifications/i18n/en.yaml b/src/notifications/i18n/en.yaml new file mode 100644 index 0000000..0f4dbd9 --- /dev/null +++ b/src/notifications/i18n/en.yaml @@ -0,0 +1,57 @@ +subject: "Registration Confirmation – Spielgruppe Pumuckl" +greeting: "Dear {name}" +intro: >- + Your registration with Spielgruppe Pumuckl has been received. + Here is a summary: + +child_section: "Child Details" +child_name: "Name" +child_dob: "Date of birth" +child_needs: "Special needs" + +booking_section: "Playgroup Booking" +booking_type: "Type" +booking_days: "Days" + +fees_section: "Fees" +monthly_fee: "Monthly subscription" +reg_fee: "Registration fee (one-time, first year)" +reg_fee_amount: "CHF 80.00" +deposit: "Cleaning deposit – indoor (refundable)" +deposit_amount: "CHF 50.00" + +parent_section: "Your Contact Details" +parent_name: "Name" +parent_address: "Address" +parent_phone: "Phone" +parent_email: "Email" + +emergency_section: "Emergency Contact" +emergency_name: "Name" +emergency_phone: "Phone" + +payment_section: "Payment Details" +payment_intro: >- + Please transfer the registration fee of CHF 80.00 to the account below. + You can scan the QR code with your banking app: +payment_intro_text: "Please transfer the registration fee of CHF 80.00 to the following account:" +iban_label: "IBAN" +payee_label: "Payee" +amount_label: "Amount" + +closing: | + If you have any questions, we are happy to help. We look forward to welcoming your child! + + Kind regards + Spielgruppe Pumuckl + +none: "None" + +days: + monday: "Monday" + wednesday: "Wednesday" + thursday: "Thursday" + +types: + indoor: "Indoor Playgroup" + outdoor: "Forest Playgroup" diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py index 34b11db..957c330 100644 --- a/src/notifications/notifier.py +++ b/src/notifications/notifier.py @@ -3,7 +3,6 @@ import io import logging import smtplib -from datetime import date, datetime from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -14,136 +13,23 @@ from PIL import Image, ImageDraw from qrbill import QRBill from ..models.registration import RegistrationData +from .context import ( + QR_CITY, + QR_IBAN, + QR_PAYEE, + QR_PCODE, + QR_STREET, + build_admin_new_context, + build_admin_update_context, + build_parent_context, + format_types, + load_strings, +) +from .renderer import render_template logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Bilingual string tables for parent confirmation emails -# --------------------------------------------------------------------------- - -_STRINGS_DE: dict = { - "subject": "Anmeldebestätigung – Spielgruppe Pumuckl", - "greeting": "Guten Tag {name}", - "intro": ( - "Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen. " - "Hier ist eine Zusammenfassung:" - ), - "child_section": "Angaben zum Kind", - "child_name": "Name", - "child_dob": "Geburtsdatum", - "child_needs": "Besondere Bedürfnisse", - "booking_section": "Spielgruppen-Buchung", - "booking_type": "Art", - "booking_days": "Tage", - "fees_section": "Kosten", - "monthly_fee": "Monatlicher Beitrag", - "reg_fee": "Anmeldegebühr (einmalig, erstes Jahr)", - "deposit": "Reinigungsdepot Innen (rückerstattbar)", - "parent_section": "Deine Kontaktdaten", - "parent_name": "Name", - "parent_address": "Adresse", - "parent_phone": "Telefon", - "parent_email": "E-Mail", - "emergency_section": "Notfallkontakt", - "emergency_name": "Name", - "emergency_phone": "Telefon", - "payment_section": "Zahlungsinformationen", - "payment_intro": ( - "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto. " - "Du kannst den QR-Code mit deiner Banking-App scannen:" - ), - "payment_intro_text": ( - "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:" - ), - "iban_label": "IBAN", - "payee_label": "Empfänger", - "amount_label": "Betrag", - "closing": ( - "Bei Fragen stehen wir dir gerne zur Verfügung. " - "Wir freuen uns auf dein Kind!\n\n" - "Herzliche Grüsse\n" - "Spielgruppe Pumuckl" - ), - "days": { - "monday": "Montag", - "wednesday": "Mittwoch", - "thursday": "Donnerstag", - }, - "types": { - "indoor": "Innenspielgruppe", - "outdoor": "Waldspielgruppe", - }, - "none": "Keine", - "deposit_amount": "CHF 50.00", - "reg_fee_amount": "CHF 80.00", -} - -_STRINGS_EN: dict = { - "subject": "Registration Confirmation – Spielgruppe Pumuckl", - "greeting": "Dear {name}", - "intro": ( - "Your registration with Spielgruppe Pumuckl has been received. " - "Here is a summary:" - ), - "child_section": "Child Details", - "child_name": "Name", - "child_dob": "Date of birth", - "child_needs": "Special needs", - "booking_section": "Playgroup Booking", - "booking_type": "Type", - "booking_days": "Days", - "fees_section": "Fees", - "monthly_fee": "Monthly subscription", - "reg_fee": "Registration fee (one-time, first year)", - "deposit": "Cleaning deposit – indoor (refundable)", - "parent_section": "Your Contact Details", - "parent_name": "Name", - "parent_address": "Address", - "parent_phone": "Phone", - "parent_email": "Email", - "emergency_section": "Emergency Contact", - "emergency_name": "Name", - "emergency_phone": "Phone", - "payment_section": "Payment Details", - "payment_intro": ( - "Please transfer the registration fee of CHF 80.00 to the account below. " - "You can scan the QR code with your banking app:" - ), - "payment_intro_text": ( - "Please transfer the registration fee of CHF 80.00 to the following account:" - ), - "iban_label": "IBAN", - "payee_label": "Payee", - "amount_label": "Amount", - "closing": ( - "If you have any questions, we are happy to help. " - "We look forward to welcoming your child!\n\n" - "Kind regards\n" - "Spielgruppe Pumuckl" - ), - "days": { - "monday": "Monday", - "wednesday": "Wednesday", - "thursday": "Thursday", - }, - "types": { - "indoor": "Indoor Playgroup", - "outdoor": "Forest Playgroup", - }, - "none": "None", - "deposit_amount": "CHF 50.00", - "reg_fee_amount": "CHF 80.00", -} - -# Fixed Swiss QR-bill payment data (stable bank details — not in config) -_QR_IBAN = "CH14 0900 0000 4930 8018 8" -_QR_PAYEE = "Familienverein Fällanden Spielgruppen" -_QR_STREET = "Huebwisstrase 5" -_QR_PCODE = "8117" -_QR_CITY = "Fällanden" - - class AdminNotifier: """Sends formatted admin notification emails. @@ -200,9 +86,10 @@ class AdminNotifier: subject = ( f"Neue Anmeldung: {registration.child.full_name} " - f"– {self._format_types(types)}" + f"– {format_types(types)}" ) - body = self._build_new_body(registration, registration_id, version, channel) + ctx = build_admin_new_context(registration, registration_id, version, channel) + body = render_template("admin_new.txt.j2", ctx) self._send( to=to_addresses, @@ -231,7 +118,8 @@ class AdminNotifier: return subject = f"Anmeldung aktualisiert: {registration.child.full_name}" - body = self._build_update_body(registration, registration_id, version, change_summary) + ctx = build_admin_update_context(registration, registration_id, version, change_summary) + body = render_template("admin_update.txt.j2", ctx) self._send( to=to_addresses, @@ -241,244 +129,6 @@ class AdminNotifier: reply_to=registration.parent_guardian.email or "", ) - # ------------------------------------------------------------------ - # Routing helpers - # ------------------------------------------------------------------ - - def _recipients_for(self, types: list[str]) -> list[str]: - """Return To addresses based on which playgroup types are booked.""" - recipients = [] - if "indoor" in types and self._indoor_email: - recipients.append(self._indoor_email) - if "outdoor" in types and self._outdoor_email: - recipients.append(self._outdoor_email) - return recipients - - # ------------------------------------------------------------------ - # Formatting helpers - # ------------------------------------------------------------------ - - @staticmethod - def _format_types(types: list[str]) -> str: - has_indoor = "indoor" in types - has_outdoor = "outdoor" in types - if has_indoor and has_outdoor: - return "Innen- und Waldspielgruppe" - if has_indoor: - return "Innenspielgruppe" - if has_outdoor: - return "Waldspielgruppe" - return "Spielgruppe" - - @staticmethod - def _calculate_age(dob_str: str) -> str: - try: - dob = datetime.strptime(dob_str, "%Y-%m-%d").date() - today = date.today() - years = today.year - dob.year - ( - (today.month, today.day) < (dob.month, dob.day) - ) - months = (today.month - dob.month) % 12 - return f"{years} Jahre, {months} Monate" - except Exception: - return dob_str - - @staticmethod - def _format_dob(dob_str: str) -> str: - try: - return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y") - except Exception: - return dob_str or "" - - @staticmethod - def _calculate_monthly_fee(registration: RegistrationData) -> str: - indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor") - outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor") - fee = 0 - if indoor_days == 1: - fee += 130 - elif indoor_days == 2: - fee += 260 - elif indoor_days >= 3: - fee += 390 - if outdoor_days >= 1: - fee += 250 - return f"CHF {fee}.-" - - @staticmethod - def _format_days(registration: RegistrationData) -> str: - day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"} - type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"} - return ", ".join( - f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})" - for d in registration.booking.selected_days - ) - - @staticmethod - def _format_change_summary(change_summary: dict) -> str: - """Render field changes as a human-readable list.""" - lines = [] - for field_path, values in sorted(change_summary.items()): - old_val, new_val = values["old"], values["new"] - lines.append(f" {field_path}:") - lines.append(f" Alt: {old_val}") - lines.append(f" Neu: {new_val}") - return "\n".join(lines) if lines else " (keine Änderungen erkannt)" - - # ------------------------------------------------------------------ - # Email body builders - # ------------------------------------------------------------------ - - def _build_new_body( - self, - registration: RegistrationData, - registration_id: str, - version: int, - channel: str, - ) -> str: - now = datetime.utcnow() - pg = registration.parent_guardian - ec = registration.emergency_contact - channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title()) - - return ( - "===============================================\n" - "NEUE SPIELGRUPPEN-ANMELDUNG\n" - "===============================================\n" - "\n" - f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n" - f"Kanal: {channel_de}\n" - f"Anmelde-ID: {registration_id} (Version {version})\n" - "\n" - "-----------------------------------------------\n" - "ANGABEN ZUM KIND\n" - "-----------------------------------------------\n" - f"Name: {registration.child.full_name}\n" - f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} " - f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n" - f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n" - "\n" - "-----------------------------------------------\n" - "SPIELGRUPPEN-AUSWAHL\n" - "-----------------------------------------------\n" - f"Art: {self._format_types(registration.booking.playgroup_types)}\n" - f"Tage: {self._format_days(registration)}\n" - "\n" - f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n" - "(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n" - "\n" - "-----------------------------------------------\n" - "ELTERN / ERZIEHUNGSBERECHTIGTE\n" - "-----------------------------------------------\n" - f"Name: {pg.full_name}\n" - f"Adresse: {pg.street_address}\n" - f" {pg.postal_code} {pg.city}\n" - f"Telefon: {pg.phone}\n" - f"E-Mail: {pg.email}\n" - "\n" - "-----------------------------------------------\n" - "NOTFALLKONTAKT\n" - "-----------------------------------------------\n" - f"Name: {ec.full_name}\n" - f"Telefon: {ec.phone}\n" - "\n" - "===============================================\n" - "\n" - "Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n" - ) - - def _build_update_body( - self, - registration: RegistrationData, - registration_id: str, - version: int, - change_summary: dict, - ) -> str: - now = datetime.utcnow() - pg = registration.parent_guardian - - return ( - "===============================================\n" - "ANMELDUNGS-AKTUALISIERUNG\n" - "===============================================\n" - "\n" - f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n" - f"Anmelde-ID: {registration_id} (Version {version})\n" - f"Kind: {registration.child.full_name}\n" - f"Eltern-E-Mail: {pg.email}\n" - "\n" - "-----------------------------------------------\n" - "WAS HAT SICH GEÄNDERT\n" - "-----------------------------------------------\n" - f"{self._format_change_summary(change_summary)}\n" - "\n" - "-----------------------------------------------\n" - "AKTUELLE ANMELDUNG (nach Aktualisierung)\n" - "-----------------------------------------------\n" - f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n" - f"Tage: {self._format_days(registration)}\n" - f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n" - "\n" - f"Elternteil: {pg.full_name}\n" - f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n" - f"Telefon: {pg.phone}\n" - "\n" - "===============================================\n" - "\n" - "Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n" - ) - - # ------------------------------------------------------------------ - # SMTP dispatch - # ------------------------------------------------------------------ - - def _send( - self, - to: list[str], - cc: list[str], - subject: str, - body: str, - reply_to: str = "", - ) -> None: - if not self._smtp_host: - logger.warning( - "SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s", - to, - cc, - subject, - ) - logger.debug("Notification body:\n%s", body) - return - - msg = MIMEMultipart("alternative") - msg["From"] = self._from_email - msg["To"] = ", ".join(to) - msg["CC"] = ", ".join(cc) - msg["Subject"] = subject - if reply_to: - msg["Reply-To"] = reply_to - - msg.attach(MIMEText(body, "plain", "utf-8")) - all_recipients = to + cc - - try: - if self._use_tls: - server = smtplib.SMTP(self._smtp_host, self._smtp_port) - server.starttls() - else: - server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port) - - server.login(self._username, self._password) - server.sendmail(self._from_email, all_recipients, msg.as_string()) - server.quit() - logger.info("Notification sent to %s", all_recipients) - except Exception: - logger.exception("Failed to send notification to %s", all_recipients) - - # ------------------------------------------------------------------ - # Parent confirmation email - # ------------------------------------------------------------------ - def notify_parent( self, registration: RegistrationData, @@ -490,7 +140,7 @@ class AdminNotifier: logger.warning("No parent email in registration — confirmation not sent.") return - strings = _STRINGS_EN if language == "en" else _STRINGS_DE + strings = load_strings(language) try: qr_png = self._generate_qr_bill_png() @@ -498,8 +148,9 @@ class AdminNotifier: logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation") qr_png = None - html_body = self._build_parent_html(registration, strings, has_qr=qr_png is not None) - text_body = self._build_parent_text(registration, strings) + ctx = build_parent_context(registration, strings, has_qr=qr_png is not None) + html_body = render_template("parent_confirmation.html.j2", ctx) + text_body = render_template("parent_confirmation.txt.j2", ctx) subject = strings["subject"] if not self._smtp_host: @@ -552,6 +203,23 @@ class AdminNotifier: except Exception: logger.exception("Failed to send parent confirmation to %s", parent_email) + # ------------------------------------------------------------------ + # Routing helpers + # ------------------------------------------------------------------ + + def _recipients_for(self, types: list[str]) -> list[str]: + """Return To addresses based on which playgroup types are booked.""" + recipients = [] + if "indoor" in types and self._indoor_email: + recipients.append(self._indoor_email) + if "outdoor" in types and self._outdoor_email: + recipients.append(self._outdoor_email) + return recipients + + # ------------------------------------------------------------------ + # QR-bill generation + # ------------------------------------------------------------------ + @staticmethod def _generate_qr_bill_png() -> bytes: """Generate a Swiss QR-bill payment QR code as a PNG image. @@ -563,12 +231,12 @@ class AdminNotifier: PNG image bytes of the QR code. """ bill = QRBill( - account=_QR_IBAN, + account=QR_IBAN, creditor={ - "name": _QR_PAYEE, - "street": _QR_STREET, - "pcode": _QR_PCODE, - "city": _QR_CITY, + "name": QR_PAYEE, + "street": QR_STREET, + "pcode": QR_PCODE, + "city": QR_CITY, "country": "CH", }, amount="80.00", @@ -602,197 +270,49 @@ class AdminNotifier: pil_img.save(buf, format="PNG") return buf.getvalue() - def _build_parent_html( + # ------------------------------------------------------------------ + # SMTP dispatch + # ------------------------------------------------------------------ + + def _send( self, - registration: RegistrationData, - strings: dict, - has_qr: bool = True, - ) -> str: - """Render the HTML body for the parent confirmation email.""" - pg = registration.parent_guardian - ec = registration.emergency_contact - ch = registration.child - - parent_name = pg.full_name or pg.email or "" - greeting = strings["greeting"].format(name=parent_name) - dob_display = self._format_dob(ch.date_of_birth or "") - special_needs = ch.special_needs or strings["none"] - pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings) - pg_days = self._format_days_bilingual(registration, strings) - monthly_fee = self._calculate_monthly_fee(registration) - has_indoor = "indoor" in registration.booking.playgroup_types - - qr_section = "" - if has_qr: - qr_section = ( - '
' - 'Swiss QR-Bill' - "
" + to: list[str], + cc: list[str], + subject: str, + body: str, + reply_to: str = "", + ) -> None: + if not self._smtp_host: + logger.warning( + "SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s", + to, + cc, + subject, ) + logger.debug("Notification body:\n%s", body) + return - deposit_row = "" - if has_indoor: - deposit_row = ( - f"{strings['deposit']}" - f"{strings['deposit_amount']}" - ) + msg = MIMEMultipart("alternative") + msg["From"] = self._from_email + msg["To"] = ", ".join(to) + msg["CC"] = ", ".join(cc) + msg["Subject"] = subject + if reply_to: + msg["Reply-To"] = reply_to - return f""" - - - + msg.attach(MIMEText(body, "plain", "utf-8")) + all_recipients = to + cc -
-

Spielgruppe Pumuckl

-

Familienverein Fällanden

-
+ try: + if self._use_tls: + server = smtplib.SMTP(self._smtp_host, self._smtp_port) + server.starttls() + else: + server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port) -
- -

{greeting},

-

{strings['intro']}

- -

{strings['child_section']}

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

{strings['booking_section']}

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

{strings['fees_section']}

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

{strings['payment_section']}

-

{strings['payment_intro']}

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

{strings['parent_section']}

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

{strings['emergency_section']}

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

{strings['closing']}

- -
- -""" - - def _build_parent_text(self, registration: RegistrationData, strings: dict) -> str: - """Render the plain-text fallback for the parent confirmation email.""" - pg = registration.parent_guardian - ec = registration.emergency_contact - ch = registration.child - - parent_name = pg.full_name or pg.email or "" - greeting = strings["greeting"].format(name=parent_name) - dob_display = self._format_dob(ch.date_of_birth or "") - special_needs = ch.special_needs or strings["none"] - pg_types = self._format_types_bilingual(registration.booking.playgroup_types, strings) - pg_days = self._format_days_bilingual(registration, strings) - monthly_fee = self._calculate_monthly_fee(registration) - has_indoor = "indoor" in registration.booking.playgroup_types - - deposit_line = "" - if has_indoor: - deposit_line = f"{strings['deposit']}: {strings['deposit_amount']}\n" - - return ( - f"{greeting},\n\n" - f"{strings['intro']}\n\n" - "===============================================\n" - f"{strings['child_section'].upper()}\n" - "===============================================\n" - f"{strings['child_name']}: {ch.full_name or ''}\n" - f"{strings['child_dob']}: {dob_display}\n" - f"{strings['child_needs']}: {special_needs}\n" - "\n" - "===============================================\n" - f"{strings['booking_section'].upper()}\n" - "===============================================\n" - f"{strings['booking_type']}: {pg_types}\n" - f"{strings['booking_days']}: {pg_days}\n" - "\n" - "===============================================\n" - f"{strings['fees_section'].upper()}\n" - "===============================================\n" - f"{strings['monthly_fee']}: {monthly_fee}\n" - f"{strings['reg_fee']}: {strings['reg_fee_amount']}\n" - f"{deposit_line}" - "\n" - "===============================================\n" - f"{strings['payment_section'].upper()}\n" - "===============================================\n" - f"{strings['payment_intro_text']}\n\n" - f" {strings['iban_label']}: CH14 0900 0000 4930 8018 8\n" - f" {strings['payee_label']}: Familienverein Fällanden Spielgruppen\n" - f" Huebwisstrase 5, 8117 Fällanden\n" - f" {strings['amount_label']}: CHF 80.00\n" - "\n" - "===============================================\n" - f"{strings['parent_section'].upper()}\n" - "===============================================\n" - f"{strings['parent_name']}: {pg.full_name or ''}\n" - f"{strings['parent_address']}: {pg.street_address or ''}, {pg.postal_code or ''} {pg.city or ''}\n" - f"{strings['parent_phone']}: {pg.phone or ''}\n" - f"{strings['parent_email']}: {pg.email or ''}\n" - "\n" - "===============================================\n" - f"{strings['emergency_section'].upper()}\n" - "===============================================\n" - f"{strings['emergency_name']}: {ec.full_name or ''}\n" - f"{strings['emergency_phone']}: {ec.phone or ''}\n" - "\n" - "-----------------------------------------------\n\n" - f"{strings['closing']}\n" - ) - - @staticmethod - def _format_types_bilingual(types: list[str], strings: dict) -> str: - """Format playgroup types using the language-specific type map.""" - type_map: dict = strings["types"] - labels = [type_map.get(t, t) for t in types] - return ", ".join(labels) if labels else "" - - @staticmethod - def _format_days_bilingual(registration: RegistrationData, strings: dict) -> str: - """Format selected days using the language-specific day map.""" - day_map: dict = strings["days"] - type_map: dict = strings["types"] - return ", ".join( - f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})" - for d in registration.booking.selected_days - ) + server.login(self._username, self._password) + server.sendmail(self._from_email, all_recipients, msg.as_string()) + server.quit() + logger.info("Notification sent to %s", all_recipients) + except Exception: + logger.exception("Failed to send notification to %s", all_recipients) diff --git a/src/notifications/renderer.py b/src/notifications/renderer.py new file mode 100644 index 0000000..2c820be --- /dev/null +++ b/src/notifications/renderer.py @@ -0,0 +1,18 @@ +"""Jinja2 template renderer for email notifications.""" + +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +_TEMPLATES_DIR = Path(__file__).parent / "templates" + +_env = Environment( + loader=FileSystemLoader(str(_TEMPLATES_DIR)), + autoescape=select_autoescape(enabled_extensions=["html.j2"]), + keep_trailing_newline=True, +) + + +def render_template(name: str, context: dict) -> str: + """Render *name* (relative to the templates directory) with *context*.""" + return _env.get_template(name).render(**context) diff --git a/src/notifications/templates/admin_new.txt.j2 b/src/notifications/templates/admin_new.txt.j2 new file mode 100644 index 0000000..2dd9029 --- /dev/null +++ b/src/notifications/templates/admin_new.txt.j2 @@ -0,0 +1,42 @@ +=============================================== +NEUE SPIELGRUPPEN-ANMELDUNG +=============================================== + +Eingereicht: {{ submitted_date }} um {{ submitted_time }} Uhr (UTC) +Kanal: {{ channel }} +Anmelde-ID: {{ registration_id }} (Version {{ version }}) + +----------------------------------------------- +ANGABEN ZUM KIND +----------------------------------------------- +Name: {{ child_name }} +Geburtsdatum: {{ child_dob }} (Alter: {{ child_age }}) +Bes. Bedürfnisse: {{ child_needs }} + +----------------------------------------------- +SPIELGRUPPEN-AUSWAHL +----------------------------------------------- +Art: {{ playgroup_types }} +Tage: {{ days }} + +Monatlicher Beitrag: {{ monthly_fee }} +(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung) + +----------------------------------------------- +ELTERN / ERZIEHUNGSBERECHTIGTE +----------------------------------------------- +Name: {{ parent_name }} +Adresse: {{ parent_street }} + {{ parent_postal_code }} {{ parent_city }} +Telefon: {{ parent_phone }} +E-Mail: {{ parent_email }} + +----------------------------------------------- +NOTFALLKONTAKT +----------------------------------------------- +Name: {{ emergency_name }} +Telefon: {{ emergency_phone }} + +=============================================== + +Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht. diff --git a/src/notifications/templates/admin_update.txt.j2 b/src/notifications/templates/admin_update.txt.j2 new file mode 100644 index 0000000..e45ddd9 --- /dev/null +++ b/src/notifications/templates/admin_update.txt.j2 @@ -0,0 +1,33 @@ +=============================================== +ANMELDUNGS-AKTUALISIERUNG +=============================================== + +Aktualisiert: {{ updated_date }} um {{ updated_time }} Uhr (UTC) +Anmelde-ID: {{ registration_id }} (Version {{ version }}) +Kind: {{ child_name }} +Eltern-E-Mail: {{ parent_email }} + +----------------------------------------------- +WAS HAT SICH GEÄNDERT +----------------------------------------------- +{% for change in changes %} + {{ change.field }}: + Alt: {{ change.old }} + Neu: {{ change.new }} +{% else %} + (keine Änderungen erkannt) +{% endfor %} +----------------------------------------------- +AKTUELLE ANMELDUNG (nach Aktualisierung) +----------------------------------------------- +Spielgruppe: {{ playgroup_types }} +Tage: {{ days }} +Monatl. Beitrag: {{ monthly_fee }} + +Elternteil: {{ parent_name }} +Adresse: {{ parent_street }}, {{ parent_postal_code }} {{ parent_city }} +Telefon: {{ parent_phone }} + +=============================================== + +Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht. diff --git a/src/notifications/templates/parent_confirmation.html.j2 b/src/notifications/templates/parent_confirmation.html.j2 new file mode 100644 index 0000000..2dd86cf --- /dev/null +++ b/src/notifications/templates/parent_confirmation.html.j2 @@ -0,0 +1,81 @@ + + + + + + + + +
+

Spielgruppe Pumuckl

+

Familienverein Fällanden

+
+ +
+ +

{{ greeting }},

+

{{ strings.intro }}

+ +

{{ strings.child_section }}

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

{{ strings.booking_section }}

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

{{ strings.fees_section }}

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

{{ strings.payment_section }}

+

{{ strings.payment_intro | safe }}

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

{{ strings.parent_section }}

+ + + + + +
{{ strings.parent_name }}{{ parent_name }}
{{ strings.parent_address }}{{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}
{{ strings.parent_phone }}{{ parent_phone }}
{{ strings.parent_email }}{{ parent_email }}
+ +

{{ strings.emergency_section }}

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

{{ strings.closing }}

+ +
+ + diff --git a/src/notifications/templates/parent_confirmation.txt.j2 b/src/notifications/templates/parent_confirmation.txt.j2 new file mode 100644 index 0000000..1c26c28 --- /dev/null +++ b/src/notifications/templates/parent_confirmation.txt.j2 @@ -0,0 +1,51 @@ +{{ greeting }}, + +{{ strings.intro }} + +=============================================== +{{ strings.child_section | upper }} +=============================================== +{{ strings.child_name }}: {{ child_name }} +{{ strings.child_dob }}: {{ child_dob }} +{{ strings.child_needs }}: {{ child_needs }} + +=============================================== +{{ strings.booking_section | upper }} +=============================================== +{{ strings.booking_type }}: {{ playgroup_types }} +{{ strings.booking_days }}: {{ days }} + +=============================================== +{{ strings.fees_section | upper }} +=============================================== +{{ strings.monthly_fee }}: {{ monthly_fee }} +{{ strings.reg_fee }}: {{ strings.reg_fee_amount }} +{% if has_indoor %}{{ strings.deposit }}: {{ strings.deposit_amount }} +{% endif %} +=============================================== +{{ strings.payment_section | upper }} +=============================================== +{{ strings.payment_intro_text }} + + {{ strings.iban_label }}: {{ iban }} + {{ strings.payee_label }}: {{ payee }} + {{ payee_street }}, {{ payee_postal_code }} {{ payee_city }} + {{ strings.amount_label }}: CHF 80.00 + +=============================================== +{{ strings.parent_section | upper }} +=============================================== +{{ strings.parent_name }}: {{ parent_name }} +{{ strings.parent_address }}: {{ parent_address }}, {{ parent_postal_code }} {{ parent_city }} +{{ strings.parent_phone }}: {{ parent_phone }} +{{ strings.parent_email }}: {{ parent_email }} + +=============================================== +{{ strings.emergency_section | upper }} +=============================================== +{{ strings.emergency_name }}: {{ emergency_name }} +{{ strings.emergency_phone }}: {{ emergency_phone }} + +----------------------------------------------- + +{{ strings.closing }} diff --git a/tests/test_notifier.py b/tests/test_notifier.py index abb08d1..03c598e 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -1,11 +1,18 @@ -"""Tests for AdminNotifier helper methods.""" +"""Tests for AdminNotifier and notification helper functions.""" import email from email.header import decode_header import pytest -from src.notifications.notifier import AdminNotifier, _STRINGS_DE, _STRINGS_EN +from src.notifications.notifier import AdminNotifier +from src.notifications.context import ( + calculate_age, + calculate_monthly_fee, + format_types, + load_strings, +) +from src.notifications.renderer import render_template from src.models.registration import RegistrationData, Booking, BookingDay @@ -36,53 +43,55 @@ def notifier(): # --------------------------------------------------------------------------- -# _format_types +# format_types # --------------------------------------------------------------------------- class TestFormatTypes: - def test_indoor_label(self, notifier): - assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower() + def test_indoor_label(self): + result = format_types(["indoor"]) + assert "Innen" in result or "indoor" in result.lower() - def test_outdoor_label(self, notifier): - assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower() + def test_outdoor_label(self): + result = format_types(["outdoor"]) + assert "Wald" in result or "outdoor" in result.lower() - def test_both_labels(self, notifier): - result = notifier._format_types(["indoor", "outdoor"]) + def test_both_labels(self): + result = format_types(["indoor", "outdoor"]) assert len(result) > 0 # --------------------------------------------------------------------------- -# _calculate_age +# calculate_age # --------------------------------------------------------------------------- class TestCalculateAge: - def test_returns_age_string(self, notifier): - result = notifier._calculate_age("2022-01-01") + def test_returns_age_string(self): + result = calculate_age("2022-01-01") assert isinstance(result, str) assert len(result) > 0 - def test_invalid_dob_returns_original_string(self, notifier): - result = notifier._calculate_age("not-a-date") + def test_invalid_dob_returns_original_string(self): + result = calculate_age("not-a-date") assert result == "not-a-date" # --------------------------------------------------------------------------- -# _calculate_monthly_fee +# calculate_monthly_fee # --------------------------------------------------------------------------- class TestCalculateMonthlyFee: - def test_indoor_one_day(self, notifier, complete_registration): + def test_indoor_one_day(self, complete_registration): complete_registration.booking = Booking( playgroup_types=["indoor"], selected_days=[BookingDay(day="monday", type="indoor")], ) - fee = notifier._calculate_monthly_fee(complete_registration) + fee = calculate_monthly_fee(complete_registration) assert "130" in fee - def test_indoor_two_days(self, notifier, complete_registration): + def test_indoor_two_days(self, complete_registration): complete_registration.booking = Booking( playgroup_types=["indoor"], selected_days=[ @@ -90,10 +99,10 @@ class TestCalculateMonthlyFee: BookingDay(day="wednesday", type="indoor"), ], ) - fee = notifier._calculate_monthly_fee(complete_registration) + fee = calculate_monthly_fee(complete_registration) assert "260" in fee - def test_indoor_three_days(self, notifier, complete_registration): + def test_indoor_three_days(self, complete_registration): complete_registration.booking = Booking( playgroup_types=["indoor"], selected_days=[ @@ -102,15 +111,15 @@ class TestCalculateMonthlyFee: BookingDay(day="thursday", type="indoor"), ], ) - fee = notifier._calculate_monthly_fee(complete_registration) + fee = calculate_monthly_fee(complete_registration) assert "390" in fee - def test_outdoor_one_day(self, notifier, complete_registration): + def test_outdoor_one_day(self, complete_registration): complete_registration.booking = Booking( playgroup_types=["outdoor"], selected_days=[BookingDay(day="monday", type="outdoor")], ) - fee = notifier._calculate_monthly_fee(complete_registration) + fee = calculate_monthly_fee(complete_registration) assert "250" in fee @@ -121,7 +130,6 @@ class TestCalculateMonthlyFee: class TestSend: def test_send_calls_smtp(self, notifier, mocker): - # _send uses smtplib.SMTP directly (not as context manager) mock_smtp_cls = mocker.patch("smtplib.SMTP") mock_server = mock_smtp_cls.return_value @@ -146,7 +154,7 @@ class TestSend: ) call_args = mock_server.sendmail.call_args - recipients = call_args[0][1] # positional arg: to_addrs + recipients = call_args[0][1] assert "a@example.com" in recipients assert "b@example.com" in recipients @@ -235,16 +243,20 @@ class TestNotifyParent: mock_smtp_cls.assert_not_called() - def test_notify_parent_text_body_contains_iban(self, notifier_no_smtp, complete_registration): + def test_notify_parent_text_body_contains_iban(self, complete_registration): """Plain-text body includes the IBAN so payment is possible without the QR image.""" - text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_DE) + strings = load_strings("de") + from src.notifications.context import build_parent_context + ctx = build_parent_context(complete_registration, strings, has_qr=False) + text = render_template("parent_confirmation.txt.j2", ctx) assert "CH14" in text - def test_notify_parent_text_body_english_contains_iban( - self, notifier_no_smtp, complete_registration - ): + def test_notify_parent_text_body_english_contains_iban(self, complete_registration): """English plain-text body also includes the IBAN.""" - text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_EN) + strings = load_strings("en") + from src.notifications.context import build_parent_context + ctx = build_parent_context(complete_registration, strings, has_qr=False) + text = render_template("parent_confirmation.txt.j2", ctx) assert "CH14" in text @@ -263,5 +275,4 @@ class TestGenerateQrBillPng: def test_returns_png_signature(self, notifier): """Output starts with the PNG magic bytes.""" png = notifier._generate_qr_bill_png() - # PNG files start with the 8-byte signature \x89PNG\r\n\x1a\n assert png[:4] == b"\x89PNG" From f1d0ad045cd07f06489b26aa704acbd7bf64f49a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 15:06:47 +0000 Subject: [PATCH 08/12] simplify calculate_monthly_fee to linear formula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rates are CHF 130/indoor day and CHF 250/outdoor day — exactly linear. Replace the if/elif table with indoor_days * 130 + outdoor_days * 250. https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- src/notifications/context.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/notifications/context.py b/src/notifications/context.py index 19981d3..7a2feaa 100644 --- a/src/notifications/context.py +++ b/src/notifications/context.py @@ -104,15 +104,7 @@ 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 + fee = indoor_days * 130 + outdoor_days * 250 return f"CHF {fee}.-" From 14fde88c8924b156e8b89275a679ac2319407c06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 16:08:27 +0000 Subject: [PATCH 09/12] replace static translation files with LLM-based i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of maintaining a YAML file per language, German (de.yaml) is the single source of truth. For any other language, the label strings are translated on demand via an LLM call and cached in memory — no static files to maintain, any language the parent writes in is served automatically. - Add src/notifications/i18n.py: get_strings(), _translate() via litellm.completion, in-memory cache, clear_cache() for tests - Passthrough keys (reg_fee_amount, deposit_amount) are never sent to the LLM so currency amounts are guaranteed to be unchanged - Falls back to German silently if the LLM call fails - Remove src/notifications/i18n/en.yaml (no longer needed) - Remove load_strings() from context.py (moved to i18n.py) - Add model parameter to AdminNotifier (defaults to claude-haiku) - Add TestGetStrings suite covering: no LLM for German, LLM called for others, caching, fallback, passthrough key preservation - Add autouse reset_translation_cache fixture to isolate tests https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna --- src/notifications/context.py | 19 ----- src/notifications/i18n.py | 103 +++++++++++++++++++++++++++ src/notifications/i18n/en.yaml | 57 --------------- src/notifications/notifier.py | 6 +- tests/test_notifier.py | 123 +++++++++++++++++++++++++-------- 5 files changed, 203 insertions(+), 105 deletions(-) create mode 100644 src/notifications/i18n.py delete mode 100644 src/notifications/i18n/en.yaml diff --git a/src/notifications/context.py b/src/notifications/context.py index 7a2feaa..aba0cec 100644 --- a/src/notifications/context.py +++ b/src/notifications/context.py @@ -1,9 +1,6 @@ """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 @@ -17,22 +14,6 @@ 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) # --------------------------------------------------------------------------- diff --git a/src/notifications/i18n.py b/src/notifications/i18n.py new file mode 100644 index 0000000..e900795 --- /dev/null +++ b/src/notifications/i18n.py @@ -0,0 +1,103 @@ +"""i18n support for parent confirmation emails. + +German is the canonical source language (de.yaml). For any other language +the German labels are translated on-demand via an LLM call and cached +in-process for the lifetime of the server — no static translation files to +maintain, any language the parent writes in is supported automatically. +""" + +import json +import logging +from pathlib import Path + +import litellm +import yaml + +logger = logging.getLogger(__name__) + +_I18N_DIR = Path(__file__).parent / "i18n" + +# In-memory translation cache keyed by language code. +_cache: dict[str, dict] = {} + +# Pure data values that must never be sent to the LLM for translation. +_PASSTHROUGH_KEYS = {"reg_fee_amount", "deposit_amount"} + +_SYSTEM_PROMPT = """\ +You are a translation assistant for a Swiss playgroup registration system. +Translate the following JSON label strings from German into {language}. + +Rules: +- Return ONLY a valid JSON object with the exact same keys and structure. +- Preserve all {{placeholder}} variables exactly as-is (e.g. {{name}}). +- Preserve all HTML tags and entities exactly (e.g. ,  ). +- Keep proper nouns untranslated: "Spielgruppe Pumuckl", "Familienverein Fällanden". +- Do not include any explanation or text outside the JSON.""" + + +def get_strings(language: str, model: str) -> dict: + """Return the label string table for *language*. + + For German, loads directly from de.yaml (no LLM call). + For all other languages, translates the German labels via LLM and caches + the result in memory. Falls back to German if the LLM call fails. + """ + if language == "de": + return _load_german() + + if language in _cache: + return _cache[language] + + german = _load_german() + translated = _translate(german, language, model) + _cache[language] = translated + return translated + + +def clear_cache() -> None: + """Evict all cached translations (intended for use in tests).""" + _cache.clear() + + +def _load_german() -> dict: + with (_I18N_DIR / "de.yaml").open(encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +def _translate(german: dict, language: str, model: str) -> dict: + """Translate the German label dict into *language* via LLM. + + Returns the German dict unchanged if the LLM call fails or returns + malformed JSON. + """ + passthrough = {k: german[k] for k in _PASSTHROUGH_KEYS if k in german} + to_translate = {k: v for k, v in german.items() if k not in _PASSTHROUGH_KEYS} + + system = _SYSTEM_PROMPT.format(language=language) + payload = json.dumps(to_translate, ensure_ascii=False, indent=2) + + try: + response = litellm.completion( + model=model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": payload}, + ], + max_tokens=2048, + ) + raw = response.choices[0].message.content.strip() + + # Strip markdown code fences that some models add + if raw.startswith("```"): + raw = raw[raw.index("\n") + 1 :] + raw = raw[: raw.rfind("```")] + + translated: dict = json.loads(raw) + translated.update(passthrough) + return translated + + except Exception: + logger.exception( + "Failed to translate email labels into %s — falling back to German", language + ) + return german diff --git a/src/notifications/i18n/en.yaml b/src/notifications/i18n/en.yaml deleted file mode 100644 index 0f4dbd9..0000000 --- a/src/notifications/i18n/en.yaml +++ /dev/null @@ -1,57 +0,0 @@ -subject: "Registration Confirmation – Spielgruppe Pumuckl" -greeting: "Dear {name}" -intro: >- - Your registration with Spielgruppe Pumuckl has been received. - Here is a summary: - -child_section: "Child Details" -child_name: "Name" -child_dob: "Date of birth" -child_needs: "Special needs" - -booking_section: "Playgroup Booking" -booking_type: "Type" -booking_days: "Days" - -fees_section: "Fees" -monthly_fee: "Monthly subscription" -reg_fee: "Registration fee (one-time, first year)" -reg_fee_amount: "CHF 80.00" -deposit: "Cleaning deposit – indoor (refundable)" -deposit_amount: "CHF 50.00" - -parent_section: "Your Contact Details" -parent_name: "Name" -parent_address: "Address" -parent_phone: "Phone" -parent_email: "Email" - -emergency_section: "Emergency Contact" -emergency_name: "Name" -emergency_phone: "Phone" - -payment_section: "Payment Details" -payment_intro: >- - Please transfer the registration fee of CHF 80.00 to the account below. - You can scan the QR code with your banking app: -payment_intro_text: "Please transfer the registration fee of CHF 80.00 to the following account:" -iban_label: "IBAN" -payee_label: "Payee" -amount_label: "Amount" - -closing: | - If you have any questions, we are happy to help. We look forward to welcoming your child! - - Kind regards - Spielgruppe Pumuckl - -none: "None" - -days: - monday: "Monday" - wednesday: "Wednesday" - thursday: "Thursday" - -types: - indoor: "Indoor Playgroup" - outdoor: "Forest Playgroup" diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py index 957c330..6a89c1c 100644 --- a/src/notifications/notifier.py +++ b/src/notifications/notifier.py @@ -23,8 +23,8 @@ from .context import ( build_admin_update_context, build_parent_context, format_types, - load_strings, ) +from .i18n import get_strings from .renderer import render_template logger = logging.getLogger(__name__) @@ -51,6 +51,7 @@ class AdminNotifier: indoor_email: str = "", outdoor_email: str = "", cc_emails: list[str] | None = None, + model: str = "anthropic/claude-haiku-4-5-20251001", ) -> None: self._smtp_host = smtp_host self._smtp_port = smtp_port @@ -61,6 +62,7 @@ class AdminNotifier: self._indoor_email = indoor_email self._outdoor_email = outdoor_email self._cc_emails: list[str] = cc_emails or [] + self._model = model # ------------------------------------------------------------------ # Public API @@ -140,7 +142,7 @@ class AdminNotifier: logger.warning("No parent email in registration — confirmation not sent.") return - strings = load_strings(language) + strings = get_strings(language, self._model) try: qr_png = self._generate_qr_bill_png() diff --git a/tests/test_notifier.py b/tests/test_notifier.py index 03c598e..53960a8 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -1,6 +1,7 @@ """Tests for AdminNotifier and notification helper functions.""" import email +import json from email.header import decode_header import pytest @@ -10,8 +11,9 @@ from src.notifications.context import ( calculate_age, calculate_monthly_fee, format_types, - load_strings, + build_parent_context, ) +from src.notifications.i18n import get_strings, clear_cache from src.notifications.renderer import render_template from src.models.registration import RegistrationData, Booking, BookingDay @@ -27,6 +29,14 @@ def _decoded_subject(msg_str: str) -> str: ) +@pytest.fixture(autouse=True) +def reset_translation_cache(): + """Clear the in-memory translation cache before every test.""" + clear_cache() + yield + clear_cache() + + @pytest.fixture def notifier(): return AdminNotifier( @@ -42,6 +52,18 @@ def notifier(): ) +@pytest.fixture +def notifier_no_smtp(): + """Notifier in dev mode (no SMTP host).""" + return AdminNotifier( + smtp_host="", + smtp_port=587, + username="", + password="", + from_email="agent@example.com", + ) + + # --------------------------------------------------------------------------- # format_types # --------------------------------------------------------------------------- @@ -160,20 +182,68 @@ class TestSend: # --------------------------------------------------------------------------- -# notify_parent — parent confirmation email +# get_strings — i18n / LLM translation # --------------------------------------------------------------------------- -@pytest.fixture -def notifier_no_smtp(): - """Notifier in dev mode (no SMTP host).""" - return AdminNotifier( - smtp_host="", - smtp_port=587, - username="", - password="", - from_email="agent@example.com", - ) +class TestGetStrings: + def test_german_loads_from_yaml_without_llm(self, mocker): + """German must never trigger an LLM call.""" + mock_litellm = mocker.patch("litellm.completion") + strings = get_strings("de", "some-model") + mock_litellm.assert_not_called() + assert strings["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl" + + def test_other_language_calls_llm(self, mocker): + """Non-German languages should call litellm.completion.""" + german = get_strings("de", "some-model") + translated = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"} + mock_litellm = mocker.patch("litellm.completion") + mock_litellm.return_value.choices[0].message.content = json.dumps(translated) + + result = get_strings("en", "some-model") + + mock_litellm.assert_called_once() + assert result["subject"] == "Registration Confirmation – Spielgruppe Pumuckl" + + def test_result_is_cached(self, mocker): + """The LLM is only called once per language per process lifetime.""" + german = get_strings("de", "some-model") + mock_litellm = mocker.patch("litellm.completion") + mock_litellm.return_value.choices[0].message.content = json.dumps(german) + + get_strings("fr", "some-model") + get_strings("fr", "some-model") + + assert mock_litellm.call_count == 1 + + def test_llm_failure_falls_back_to_german(self, mocker): + """If the LLM raises, the German strings are returned silently.""" + mocker.patch("litellm.completion", side_effect=RuntimeError("network error")) + + result = get_strings("it", "some-model") + + assert result["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl" + + def test_passthrough_keys_not_altered(self, mocker): + """reg_fee_amount and deposit_amount must survive translation unchanged.""" + german = get_strings("de", "some-model") + # Return translation that omits passthrough keys (as the LLM would) + without_passthrough = {k: v for k, v in german.items() + if k not in {"reg_fee_amount", "deposit_amount"}} + mocker.patch("litellm.completion").return_value.choices[0].message.content = ( + json.dumps(without_passthrough) + ) + + result = get_strings("en", "some-model") + + assert result["reg_fee_amount"] == "CHF 80.00" + assert result["deposit_amount"] == "CHF 50.00" + + +# --------------------------------------------------------------------------- +# notify_parent — parent confirmation email +# --------------------------------------------------------------------------- class TestNotifyParent: @@ -190,7 +260,7 @@ class TestNotifyParent: assert "anna.muster@example.com" in recipients def test_notify_parent_german_subject(self, notifier, complete_registration, mocker): - """German language produces a German subject line.""" + """German language produces a German subject line without any LLM call.""" mock_smtp_cls = mocker.patch("smtplib.SMTP") captured = {} @@ -204,7 +274,13 @@ class TestNotifyParent: assert "Anmeldebestätigung" in _decoded_subject(captured["msg"]) def test_notify_parent_english_subject(self, notifier, complete_registration, mocker): - """English language produces an English subject line.""" + """English language produces an English subject line via LLM translation.""" + german = get_strings("de", "some-model") + english = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"} + mocker.patch("litellm.completion").return_value.choices[0].message.content = ( + json.dumps(english) + ) + mock_smtp_cls = mocker.patch("smtplib.SMTP") captured = {} @@ -220,7 +296,9 @@ class TestNotifyParent: def test_notify_parent_unknown_language_falls_back_to_de( self, notifier, complete_registration, mocker ): - """Unsupported language codes fall back to German.""" + """When the LLM call fails, the email is sent in German.""" + mocker.patch("litellm.completion", side_effect=RuntimeError("timeout")) + mock_smtp_cls = mocker.patch("smtplib.SMTP") captured = {} @@ -243,18 +321,9 @@ class TestNotifyParent: mock_smtp_cls.assert_not_called() - 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.""" - 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, complete_registration): - """English plain-text body also includes the IBAN.""" - strings = load_strings("en") - from src.notifications.context import build_parent_context + def test_text_body_contains_iban(self, complete_registration): + """Rendered plain-text body includes the IBAN regardless of language.""" + strings = get_strings("de", "some-model") ctx = build_parent_context(complete_registration, strings, has_qr=False) text = render_template("parent_confirmation.txt.j2", ctx) assert "CH14" in text From 602b8eddc3643e07401da5b2aef87b6c45afb4da Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Mon, 23 Feb 2026 22:18:28 +0100 Subject: [PATCH 10/12] introduce separate SIMPLE_MODEL config for lightweight tasks Add a distinct simple_model field alongside ai_model so operators can route cheap, simple tasks (e.g. email-label translation) to a low-cost model while keeping the strong model for parent conversations. The two models can be from different providers (e.g. Gemini + Haiku). If SIMPLE_MODEL is unset, falls back to AI_MODEL with a warning. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 17 ++++++++++++----- chat_app.py | 1 + main.py | 1 + src/config.py | 24 ++++++++++++++++++++++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 7562246..fb46f9d 100644 --- a/.env.example +++ b/.env.example @@ -6,14 +6,21 @@ # --------------------------------------------------------------- # --------------------------------------------------------------- -# AI Model (via litellm — supports any provider) +# AI Models (via litellm — supports any provider) # --------------------------------------------------------------- # Use litellm model strings: "/" -# Examples: -# anthropic/claude-opus-4-6 (default) -# openai/gpt-4o -# gemini/gemini-2.0-flash +# +# AI_MODEL — primary model for conversation with parents (complex reasoning). +# SIMPLE_MODEL — lightweight model for simple tasks, e.g. email-label translation. +# Can be from a different provider than AI_MODEL. +# If unset, falls back to AI_MODEL with a warning in the logs. +# +# Examples — mixing providers: +# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=gemini/gemini-3-flash +# AI_MODEL=anthropic/claude-opus-4-6 + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001 +# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001 AI_MODEL=anthropic/claude-opus-4-6 +SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001 # Extended thinking — Anthropic models only (leave unset to disable). # Enables a reasoning phase before the model's reply, which improves diff --git a/chat_app.py b/chat_app.py index 6026ccb..acbcf17 100644 --- a/chat_app.py +++ b/chat_app.py @@ -58,6 +58,7 @@ _notifier = AdminNotifier( indoor_email=_config.admin_email_indoor, outdoor_email=_config.admin_email_outdoor, cc_emails=[e.strip() for e in _config.admin_email_cc.split(",") if e.strip()], + model=_config.simple_model, ) # --------------------------------------------------------------------------- diff --git a/main.py b/main.py index b434cbe..c432fb8 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ def build_components(config: Config): indoor_email=config.admin_email_indoor, outdoor_email=config.admin_email_outdoor, cc_emails=[e.strip() for e in config.admin_email_cc.split(",") if e.strip()], + model=config.simple_model, ) agent = EmailAgent( diff --git a/src/config.py b/src/config.py index fbba30e..b7f3d8f 100644 --- a/src/config.py +++ b/src/config.py @@ -1,9 +1,12 @@ """Configuration loaded from environment variables.""" +import logging import os from dataclasses import dataclass, field from pathlib import Path +logger = logging.getLogger(__name__) + try: from dotenv import load_dotenv load_dotenv() @@ -13,10 +16,17 @@ except ImportError: @dataclass class Config: - # AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o". + # Primary model for conversation with parents — litellm format. # The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …). + # Example: "anthropic/claude-opus-4-6", "google/gemini-2.0-flash", "openai/gpt-4o" ai_model: str = "anthropic/claude-opus-4-6" + # Lightweight model for simple tasks such as email-label translation. + # Can be from a different provider than ai_model. + # If not configured (SIMPLE_MODEL env var unset), falls back to ai_model with a warning. + # Example: "anthropic/claude-haiku-4-5-20251001", "openai/gpt-4o-mini" + simple_model: str = "" + # Email — IMAP (receiving) imap_host: str = "" imap_port: int = 993 @@ -57,8 +67,18 @@ class Config: @classmethod def from_env(cls) -> "Config": + ai_model = os.getenv("AI_MODEL", "anthropic/claude-opus-4-6") + simple_model = os.getenv("SIMPLE_MODEL", "") + if not simple_model: + logger.warning( + "SIMPLE_MODEL not configured — falling back to AI_MODEL (%s) for simple tasks " + "(set SIMPLE_MODEL to a cheaper model, e.g. anthropic/claude-haiku-4-5-20251001)", + ai_model, + ) + simple_model = ai_model return cls( - ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"), + ai_model=ai_model, + simple_model=simple_model, imap_host=os.getenv("IMAP_HOST", ""), imap_port=int(os.getenv("IMAP_PORT", "993")), imap_username=os.getenv("IMAP_USERNAME", ""), From 75691879aac8ec75350cbc86b2792344ad537bb0 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Mon, 23 Feb 2026 22:19:30 +0100 Subject: [PATCH 11/12] add jinja2 and pyyaml to project dependencies Co-Authored-By: Claude Sonnet 4.6 --- uv.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/uv.lock b/uv.lock index c05631d..f64585d 100644 --- a/uv.lock +++ b/uv.lock @@ -1083,10 +1083,12 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "chainlit" }, + { name = "jinja2" }, { name = "jsonschema" }, { name = "litellm" }, { name = "pillow" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "qrbill" }, ] @@ -1100,10 +1102,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "chainlit", specifier = ">=2.9.6" }, + { name = "jinja2", specifier = ">=3.0.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "litellm", specifier = ">=1.0.0" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, { name = "qrbill", specifier = ">=1.2.0" }, ] From 8275286647c408c158b2a8ec20a948402dc183bd Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Mon, 23 Feb 2026 22:51:04 +0100 Subject: [PATCH 12/12] fix gemini 3 flash model name to gemini-3-flash-preview Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index fb46f9d..8bc6631 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ # If unset, falls back to AI_MODEL with a warning in the logs. # # Examples — mixing providers: -# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=gemini/gemini-3-flash +# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=gemini/gemini-3-flash-preview # AI_MODEL=anthropic/claude-opus-4-6 + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001 # AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001 AI_MODEL=anthropic/claude-opus-4-6