Author SHA1 Message Date
gurixandClaude Sonnet 4.6 91ba940951 update language hint to reflect any-language support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:52:57 +01:00
gurixandClaude Sonnet 4.6 8275286647 fix gemini 3 flash model name to gemini-3-flash-preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:51:04 +01:00
gurixandClaude Sonnet 4.6 75691879aa add jinja2 and pyyaml to project dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:19:30 +01:00
gurixandClaude Sonnet 4.6 602b8eddc3 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 <noreply@anthropic.com>
2026-02-23 22:18:28 +01:00
Claude 14fde88c89 replace static translation files with LLM-based i18n
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
2026-02-23 16:08:27 +00:00
Claude f1d0ad045c simplify calculate_monthly_fee to linear formula
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
2026-02-23 15:06:47 +00:00
Claude 54d88d1d64 refactor notifications to MVC: extract templates, context, and renderer
- Move all email layout to Jinja2 templates (4 files: admin_new,
  admin_update, parent_confirmation html+txt)
- Extract pure helper/context-builder functions to context.py
  (format_types, calculate_age, format_dob, calculate_monthly_fee,
  format_days, build_admin_new_context, build_admin_update_context,
  build_parent_context)
- Move i18n label strings out of Python into YAML locale files
  (i18n/de.yaml, i18n/en.yaml) — admin-editable without code changes
- Add renderer.py as thin Jinja2 wrapper (render_template)
- Reduce notifier.py to routing + SMTP dispatch only
- Update tests to call context functions directly and render
  templates via renderer instead of calling private builder methods
- Add jinja2 and pyyaml as explicit dependencies in pyproject.toml

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-23 10:05:03 +00:00
Claude b2e04fa07b implement registration-confirmation-email
- 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
2026-02-22 21:23:32 +00:00
Claude 1632433b6a 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
2026-02-22 21:05:32 +00:00
Claude 9981a9121e 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
2026-02-22 20:58:17 +00:00
Claude 42a01a011e 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
2026-02-22 20:49:43 +00:00
Claude e8cf170007 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
2026-02-22 20:46:24 +00:00
Claude c6fd7497aa 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
2026-02-22 20:44:12 +00:00
Markus GrafandGitHub 43ec87e151 Merge pull request #5 from gurix/claude/implement-web-chat-ObR0B
chore: scaffold implement-web-chat OpenSpec change
2026-02-22 21:33:33 +01:00
25 changed files with 1430 additions and 200 deletions
+12 -5
View File
@@ -6,14 +6,21 @@
# ---------------------------------------------------------------
# ---------------------------------------------------------------
# AI Model (via litellm — supports any provider)
# AI Models (via litellm — supports any provider)
# ---------------------------------------------------------------
# Use litellm model strings: "<provider>/<model-name>"
# 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-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
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
+1 -1
View File
@@ -8,7 +8,7 @@ Ich helfe dir, dein Kind für die Spielgruppe anzumelden — schnell und unkompl
- **Anmeldung**: Ich führe dich Schritt für Schritt durch die Anmeldung
- **Fragen beantworten**: Preise, Zeiten, Reglement — frag einfach
- **Deutsch oder Englisch**: Schreib in der Sprache, die dir lieber ist
- **Deutsch oder jede andere Sprache**: Schreib in der Sprache, die dir lieber ist
## Spielgruppen
+11 -1
View File
@@ -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,
)
# ---------------------------------------------------------------------------
@@ -66,7 +67,7 @@ _notifier = AdminNotifier(
_WELCOME_DE = (
"Hallo! Ich bin der Anmeldeassistent der Spielgruppe Pumuckl. "
"Ich kann dir helfen, dein Kind anzumelden, oder deine Fragen zur Spielgruppe beantworten.\n\n"
"Du kannst mir auf Deutsch oder Englisch schreiben — ich antworte in derselben Sprache.\n\n"
"Du kannst mir auf die Sprache schreiben die du am besten kannst — ich antworte in derselben Sprache.\n\n"
"Womit kann ich dir helfen?"
)
@@ -172,6 +173,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()):
+1
View File
@@ -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(
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-22
@@ -0,0 +1,101 @@
## 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 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)
## 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**: 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.
**Supported values**: `"de"` (German, default) and `"en"` (English). Other values fall back to `"de"`.
**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
**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 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
- 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.**
@@ -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).
@@ -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"`
@@ -0,0 +1,52 @@
## 1. Add `qrbill` Dependency
- [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
- [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`
- [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`
- [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
- Build MIME structure: `multipart/mixed` > `multipart/alternative` > plain text part + `multipart/related` > HTML part + inline PNG (`Content-Disposition: inline`, `Content-ID: <qrbill>`)
- 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
- [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
- [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
- [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
- [ ] 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`
+4
View File
@@ -11,6 +11,10 @@ dependencies = [
# Registration schema validation
"jsonschema>=4.23.0",
"chainlit>=2.9.6",
"qrbill>=1.2.0",
"pillow>=12.1.1",
"jinja2>=3.0.0",
"pyyaml>=6.0.0",
]
[project.scripts]
+7
View File
@@ -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
+22 -2
View File
@@ -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", ""),
+204
View File
@@ -0,0 +1,204 @@
"""Pure functions for building email context dicts from registration data."""
from datetime import date, datetime
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"
# ---------------------------------------------------------------------------
# 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 = indoor_days * 130 + outdoor_days * 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,
}
+103
View File
@@ -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. <strong>, &nbsp;).
- 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
+57
View File
@@ -0,0 +1,57 @@
subject: "Anmeldebestätigung Spielgruppe Pumuckl"
greeting: "Guten Tag {name}"
intro: >-
Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen.
Hier ist eine Zusammenfassung:
child_section: "Angaben zum Kind"
child_name: "Name"
child_dob: "Geburtsdatum"
child_needs: "Besondere Bedürfnisse"
booking_section: "Spielgruppen-Buchung"
booking_type: "Art"
booking_days: "Tage"
fees_section: "Kosten"
monthly_fee: "Monatlicher Beitrag"
reg_fee: "Anmeldegebühr (einmalig, erstes Jahr)"
reg_fee_amount: "CHF 80.00"
deposit: "Reinigungsdepot Innen (rückerstattbar)"
deposit_amount: "CHF 50.00"
parent_section: "Deine Kontaktdaten"
parent_name: "Name"
parent_address: "Adresse"
parent_phone: "Telefon"
parent_email: "E-Mail"
emergency_section: "Notfallkontakt"
emergency_name: "Name"
emergency_phone: "Telefon"
payment_section: "Zahlungsinformationen"
payment_intro: >-
Bitte überweise die Anmeldegebühr von <strong>CHF&nbsp;80.00</strong> auf folgendes Konto.
Du kannst den QR-Code mit deiner Banking-App scannen:
payment_intro_text: "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:"
iban_label: "IBAN"
payee_label: "Empfänger"
amount_label: "Betrag"
closing: |
Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind!
Herzliche Grüsse
Spielgruppe Pumuckl
none: "Keine"
days:
monday: "Montag"
wednesday: "Mittwoch"
thursday: "Donnerstag"
types:
indoor: "Innenspielgruppe"
outdoor: "Waldspielgruppe"
+145 -168
View File
@@ -1,12 +1,31 @@
"""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
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,
)
from .i18n import get_strings
from .renderer import render_template
logger = logging.getLogger(__name__)
@@ -32,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
@@ -42,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
@@ -67,9 +88,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,
@@ -98,7 +120,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,
@@ -108,6 +131,80 @@ class AdminNotifier:
reply_to=registration.parent_guardian.email or "",
)
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 = get_strings(language, self._model)
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
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:
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", "<qrbill>")
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)
# ------------------------------------------------------------------
# Routing helpers
# ------------------------------------------------------------------
@@ -122,178 +219,58 @@ class AdminNotifier:
return recipients
# ------------------------------------------------------------------
# Formatting helpers
# QR-bill generation
# ------------------------------------------------------------------
@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"
def _generate_qr_bill_png() -> bytes:
"""Generate a Swiss QR-bill payment QR code as a PNG image.
@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)
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",
)
months = (today.month - dob.month) % 12
return f"{years} Jahre, {months} Monate"
except Exception:
return dob_str
payload = bill.qr_data()
@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
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")
@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)"
# 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")
# ------------------------------------------------------------------
# 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"
)
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
return buf.getvalue()
# ------------------------------------------------------------------
# SMTP dispatch
+18
View File
@@ -0,0 +1,18 @@
"""Jinja2 template renderer for email notifications."""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(enabled_extensions=["html.j2"]),
keep_trailing_newline=True,
)
def render_template(name: str, context: dict) -> str:
"""Render *name* (relative to the templates directory) with *context*."""
return _env.get_template(name).render(**context)
@@ -0,0 +1,42 @@
===============================================
NEUE SPIELGRUPPEN-ANMELDUNG
===============================================
Eingereicht: {{ submitted_date }} um {{ submitted_time }} Uhr (UTC)
Kanal: {{ channel }}
Anmelde-ID: {{ registration_id }} (Version {{ version }})
-----------------------------------------------
ANGABEN ZUM KIND
-----------------------------------------------
Name: {{ child_name }}
Geburtsdatum: {{ child_dob }} (Alter: {{ child_age }})
Bes. Bedürfnisse: {{ child_needs }}
-----------------------------------------------
SPIELGRUPPEN-AUSWAHL
-----------------------------------------------
Art: {{ playgroup_types }}
Tage: {{ days }}
Monatlicher Beitrag: {{ monthly_fee }}
(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)
-----------------------------------------------
ELTERN / ERZIEHUNGSBERECHTIGTE
-----------------------------------------------
Name: {{ parent_name }}
Adresse: {{ parent_street }}
{{ parent_postal_code }} {{ parent_city }}
Telefon: {{ parent_phone }}
E-Mail: {{ parent_email }}
-----------------------------------------------
NOTFALLKONTAKT
-----------------------------------------------
Name: {{ emergency_name }}
Telefon: {{ emergency_phone }}
===============================================
Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.
@@ -0,0 +1,33 @@
===============================================
ANMELDUNGS-AKTUALISIERUNG
===============================================
Aktualisiert: {{ updated_date }} um {{ updated_time }} Uhr (UTC)
Anmelde-ID: {{ registration_id }} (Version {{ version }})
Kind: {{ child_name }}
Eltern-E-Mail: {{ parent_email }}
-----------------------------------------------
WAS HAT SICH GEÄNDERT
-----------------------------------------------
{% for change in changes %}
{{ change.field }}:
Alt: {{ change.old }}
Neu: {{ change.new }}
{% else %}
(keine Änderungen erkannt)
{% endfor %}
-----------------------------------------------
AKTUELLE ANMELDUNG (nach Aktualisierung)
-----------------------------------------------
Spielgruppe: {{ playgroup_types }}
Tage: {{ days }}
Monatl. Beitrag: {{ monthly_fee }}
Elternteil: {{ parent_name }}
Adresse: {{ parent_street }}, {{ parent_postal_code }} {{ parent_city }}
Telefon: {{ parent_phone }}
===============================================
Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;">
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1>
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p>
</div>
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
<p>{{ greeting }},</p>
<p>{{ strings.intro }}</p>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.child_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.child_name }}</td><td>{{ child_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_dob }}</td><td>{{ child_dob }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_needs }}</td><td>{{ child_needs }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.booking_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.booking_type }}</td><td>{{ playgroup_types }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.booking_days }}</td><td>{{ days }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.fees_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.monthly_fee }}</td><td>{{ monthly_fee }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.reg_fee }}</td><td>{{ strings.reg_fee_amount }}</td></tr>
{% if has_indoor %}
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.deposit }}</td><td>{{ strings.deposit_amount }}</td></tr>
{% endif %}
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.payment_section }}</h2>
<p>{{ strings.payment_intro | safe }}</p>
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
<tr>
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{{ strings.iban_label }}</td>
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">{{ iban }}</td>
</tr>
<tr>
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{{ strings.payee_label }}</td>
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">{{ payee }}<br>{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}</td>
</tr>
<tr>
<td style="padding:8px;color:#666;">{{ strings.amount_label }}</td>
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
</tr>
</table>
{% if has_qr %}
<div style="text-align:center;margin:20px 0;">
<img src="cid:qrbill" alt="Swiss QR-Bill" style="max-width:380px;width:100%;border:1px solid #e0e0e0;">
</div>
{% endif %}
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.parent_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.parent_name }}</td><td>{{ parent_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_address }}</td><td>{{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_phone }}</td><td>{{ parent_phone }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_email }}</td><td>{{ parent_email }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.emergency_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.emergency_name }}</td><td>{{ emergency_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.emergency_phone }}</td><td>{{ emergency_phone }}</td></tr>
</table>
<p style="margin-top:24px;white-space:pre-line;">{{ strings.closing }}</p>
</div>
</body>
</html>
@@ -0,0 +1,51 @@
{{ greeting }},
{{ strings.intro }}
===============================================
{{ strings.child_section | upper }}
===============================================
{{ strings.child_name }}: {{ child_name }}
{{ strings.child_dob }}: {{ child_dob }}
{{ strings.child_needs }}: {{ child_needs }}
===============================================
{{ strings.booking_section | upper }}
===============================================
{{ strings.booking_type }}: {{ playgroup_types }}
{{ strings.booking_days }}: {{ days }}
===============================================
{{ strings.fees_section | upper }}
===============================================
{{ strings.monthly_fee }}: {{ monthly_fee }}
{{ strings.reg_fee }}: {{ strings.reg_fee_amount }}
{% if has_indoor %}{{ strings.deposit }}: {{ strings.deposit_amount }}
{% endif %}
===============================================
{{ strings.payment_section | upper }}
===============================================
{{ strings.payment_intro_text }}
{{ strings.iban_label }}: {{ iban }}
{{ strings.payee_label }}: {{ payee }}
{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}
{{ strings.amount_label }}: CHF 80.00
===============================================
{{ strings.parent_section | upper }}
===============================================
{{ strings.parent_name }}: {{ parent_name }}
{{ strings.parent_address }}: {{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}
{{ strings.parent_phone }}: {{ parent_phone }}
{{ strings.parent_email }}: {{ parent_email }}
===============================================
{{ strings.emergency_section | upper }}
===============================================
{{ strings.emergency_name }}: {{ emergency_name }}
{{ strings.emergency_phone }}: {{ emergency_phone }}
-----------------------------------------------
{{ strings.closing }}
+1
View File
@@ -254,6 +254,7 @@ class ConversationStore:
"channel": "email",
"parentEmail": state.parent_email,
"conversationId": state.conversation_id,
"language": state.language,
}
return record
+234 -24
View File
@@ -1,11 +1,42 @@
"""Tests for AdminNotifier helper methods."""
"""Tests for AdminNotifier and notification helper functions."""
import email
import json
from email.header import decode_header
import pytest
from src.notifications.notifier import AdminNotifier
from src.notifications.context import (
calculate_age,
calculate_monthly_fee,
format_types,
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
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(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(
@@ -21,54 +52,68 @@ 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
# 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=[
@@ -76,10 +121,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=[
@@ -88,15 +133,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
@@ -107,7 +152,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
@@ -132,6 +176,172 @@ 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
# ---------------------------------------------------------------------------
# get_strings — i18n / LLM translation
# ---------------------------------------------------------------------------
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:
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 without any LLM call."""
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 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 = {}
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
):
"""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 = {}
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_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
# ---------------------------------------------------------------------------
# _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()
assert png[:4] == b"\x89PNG"
+17
View File
@@ -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"
Generated
+120
View File
@@ -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"
@@ -1074,9 +1083,13 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "chainlit" },
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "litellm" },
{ name = "pillow" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "qrbill" },
]
[package.dev-dependencies]
@@ -1089,9 +1102,13 @@ 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" },
]
[package.metadata.requires-dev]
@@ -1918,6 +1935,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 +2283,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 +2341,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 +2602,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"