mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Compare commits
63
Commits
7144d961c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a54647aa | ||
|
|
7aa8a2c979 | ||
|
|
cd49c04816 | ||
|
|
2409b4d123 | ||
|
|
6a1f305202 | ||
|
|
2137ff7c84 | ||
|
|
3e949566ac | ||
|
|
0abb93c021 | ||
|
|
f80b17c443 | ||
|
|
31c1646571 | ||
|
|
9e9bb7d668 | ||
|
|
0252a0c0e3 | ||
|
|
e4138e93b6 | ||
|
|
071167b626 | ||
|
|
e87f859101 | ||
|
|
6a841c9bdd | ||
|
|
528c24ad42 | ||
|
|
ae53c168fd | ||
|
|
3b12de95ed | ||
|
|
94aec8fd18 | ||
|
|
c46662561e | ||
|
|
be042949a2 | ||
|
|
d3dbb5e96d | ||
|
|
592c5482d6 | ||
|
|
aa0c16347b | ||
|
|
d6cd58a788 | ||
|
|
1972dbabb3 | ||
|
|
69e8cd8f78 | ||
|
|
a4536eedd0 | ||
|
|
5fc6d7aec9 | ||
|
|
aab43cc0fe | ||
|
|
e3807bf7dc | ||
|
|
47f424252f | ||
|
|
ac161e8838 | ||
|
|
c68e8b7d6a | ||
|
|
cc4aaef895 | ||
|
|
6b0da82b45 | ||
|
|
b7554478ac | ||
|
|
cba463a648 | ||
|
|
f5c8d81057 | ||
|
|
85a865fa0e | ||
|
|
4951d81ea3 | ||
|
|
e7fdde8691 | ||
|
|
4e2595cec4 | ||
|
|
aa5829acb0 | ||
|
|
76e0e8326c | ||
|
|
f861732131 | ||
|
|
2ce764b007 | ||
|
|
bfb65b2583 | ||
|
|
d87a57775e | ||
|
|
25f5d51d77 | ||
|
|
a1fb2f4e30 | ||
|
|
0641285681 | ||
|
|
56a3f5f037 | ||
|
|
4ecdf87d05 | ||
|
|
186af530b0 | ||
|
|
f3bdffd347 | ||
|
|
3940926a22 | ||
|
|
d4dfd0066a | ||
|
|
a1719cad5e | ||
|
|
46795fc110 | ||
|
|
227ba8b128 | ||
|
|
ee99577b20 |
@@ -183,6 +183,80 @@ marker may share the last cell. CCMA removes both markers and clones the whole
|
||||
row, including its formatting, once per item. With no items, the template row
|
||||
is removed. A loop that is not closed in the same row is rejected.
|
||||
|
||||
## Mail templates
|
||||
|
||||
Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails,
|
||||
SEPA pre-notifications and the data-review request -- is rendered from a plain text
|
||||
template. The shipped
|
||||
defaults are copied into the store's `templates/mail/` directory on first start
|
||||
and can be edited there or under Optionen -> E-Mail-Vorlagen. An existing file is
|
||||
never overwritten; a deleted one is restored from the shipped default.
|
||||
|
||||
A template holds the subject in its first line and the message body after a blank
|
||||
line:
|
||||
|
||||
```
|
||||
Betreff: {{reminder.name}} – {{claim.title}}
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
...
|
||||
```
|
||||
|
||||
Placeholders use the same `{{ ... }}` syntax as the document templates. Which ones
|
||||
are available depends on the mail; the options dialog lists them per template and
|
||||
rejects unknown ones when saving. A line that contains nothing but placeholders
|
||||
which render empty (an optional hint, an empty list) is dropped, and
|
||||
`{{#claims}} ... {{/claims}}` repeats its content once per claim.
|
||||
|
||||
## Data-review mailing
|
||||
|
||||
"Datenüberprüfung anfragen" in the members tab asks members to check the data the
|
||||
club stores about them. Every member appears in the recipient list; preselected are
|
||||
the live memberships (accepted, active, suspended, resigned at year's end, honorary)
|
||||
that have an e-mail address. Members without one are skipped and reported.
|
||||
|
||||
Each recipient gets one mail listing their own record -- number, name, nickname,
|
||||
birth date, contact data, address, status, member since, payment frequency, and the
|
||||
bank details only for members who have any. A field with no value is printed as
|
||||
`(nicht hinterlegt)` so the gap is visible, and the IBAN is masked down to its
|
||||
country code and last four digits, which is enough to recognise the account. The
|
||||
mail closes with the reference to put on a transfer -- member number and full name,
|
||||
either of which identifies the payment on its own.
|
||||
|
||||
The text comes from the `Datenüberprüfung` template. Besides the usual member and
|
||||
organization placeholders it offers `{{data.sheet}}` (the whole record, one field per
|
||||
line), `{{data.count}}`, `{{member.iban_masked}}`, `{{payment.reference}}` (the
|
||||
suggested transfer reference), `{{data.iban_hint}}` (the note about the shortened
|
||||
IBAN, filled only for members whose bank details are listed) and the repeat block
|
||||
`{{#data}}{{field.label}}: {{field.value}}{{/data}}` for a custom layout.
|
||||
|
||||
Delivery follows the configured e-mail mode (local `.eml` files, direct send, or
|
||||
IMAP drafts); the run is confirmed once more with the recipient count because it
|
||||
cannot be taken back. Every mail is archived below the member's
|
||||
`files/documents/Datenpruefung/` directory and logged as a `data_review_email_sent`
|
||||
event. A member whose mail fails is reported as a warning and the run continues with
|
||||
the rest.
|
||||
|
||||
## Read-only stores
|
||||
|
||||
The member store may be mounted read-only -- the encrypted volume holding the
|
||||
member data does not have to be writable to look something up. CCMA detects this
|
||||
at startup by probing the store with an actual write, skips the housekeeper (every
|
||||
one of its passes writes) and opens in a read-only session: a permanent warning
|
||||
banner above the tabs, a "NUR LESEN" marker in the window title and status bar,
|
||||
and every write refused with one clear message instead of an operating system
|
||||
error.
|
||||
|
||||
Program settings still save normally -- they live in the user's config directory,
|
||||
not in the store. Settings that belong to the store (club data, member numbers,
|
||||
reminders, e-mail, mail templates) are skipped with a notice. When the volume is
|
||||
remounted with write access, "Erneut prüfen" in the banner picks that up without a
|
||||
restart: title, status bar and banner drop their markers, and the housekeeper pass
|
||||
that was skipped at startup is offered right away.
|
||||
|
||||
A store that was never initialized cannot be opened read-only: creating it needs
|
||||
write access, and CCMA says so instead of failing obscurely.
|
||||
|
||||
## Housekeeper rules
|
||||
|
||||
The housekeeper runs every rule for every member. Built-in Python rules live in
|
||||
@@ -217,4 +291,13 @@ only confirming actual dispatch starts the new payment deadline and adds an
|
||||
optional fee line item. A claim-level dunning hold suppresses automatic and
|
||||
manual reminder preparation until it is removed or expires.
|
||||
|
||||
An overdue claim of a member with an active mandate is not dunned but reported as
|
||||
a direct debit to look into -- until a reminder for it has actually been sent. A
|
||||
sent reminder (typically the "Rücklastschrift" preset for a bounced debit) takes
|
||||
that claim off the direct-debit track for good: it is expected as a transfer by
|
||||
the stated deadline, so the housekeeper continues it in the ordinary dunning
|
||||
sequence, the SEPA run leaves it out and says so, and its dunning mail asks for a
|
||||
transfer even though the mandate is still active. Reverting the sent reminder puts
|
||||
the claim back into the direct-debit run.
|
||||
|
||||
Do not place a real member store inside the source repository.
|
||||
|
||||
@@ -42,6 +42,7 @@ ccma = [
|
||||
"assets/CHANGELOG.json",
|
||||
"assets/splash.png",
|
||||
"assets/templates/*",
|
||||
"assets/mail_templates/*",
|
||||
"assets/themes/forest/**/*",
|
||||
"assets/themes/forest/*",
|
||||
]
|
||||
|
||||
@@ -29,7 +29,21 @@
|
||||
"Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden.",
|
||||
"Beim Anklicken einer noch nicht zugeordneten Forderung oder Spende im Zahlungsfenster wird der Betrag automatisch mit dem sinnvollen Vorschlag vorausgefüllt.",
|
||||
"Individuell abweichende Mitgliedsbeiträge (z. B. ermäßigter Beitrag für Schüler) können pro Mitglied mit Zeitraum, Begründung und wahlweise als fester Jahresbetrag oder prozentualer Ermäßigung hinterlegt werden; künftig erzeugte Beitragsforderungen berücksichtigen das monatsgenau, auch wenn die Ermäßigung mitten in einer Zahlungsperiode beginnt oder endet.",
|
||||
"Der Hausmeister kann gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt."
|
||||
"Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt.",
|
||||
"Eine Mahnung kann statt einer einzelnen Gebühr mehrere frei bearbeitbare Positionen (Beschreibung & Betrag) enthalten; für eine nicht eingelöste Lastschrift steht eine eigene Rücklastschrift-Vorlage bereit, die dieselbe Mahnstufe wie eine reguläre Mahnung belegt und einen vorausgefüllten Hinweistext für die Mahnungs-E-Mail mitbringt.",
|
||||
"Mahnstufen, Zahlungsfristen und Standardpositionen (Bezeichnung & Betrag) für Mahnungen lassen sich jetzt unter Optionen → Mahnungen zentral konfigurieren.",
|
||||
"Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden.",
|
||||
"Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.",
|
||||
"Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt.",
|
||||
"Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.",
|
||||
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
|
||||
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
|
||||
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt.",
|
||||
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
|
||||
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
||||
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
|
||||
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name).",
|
||||
"Eine Forderung, für die bereits eine Mahnung bzw. Rücklastschrift-Zahlungserinnerung versandt wurde, wird nicht mehr als Lastschrift behandelt: Der Hausmeister meldet nach Ablauf der Zahlungsfrist die nächste Mahnstufe statt weiterhin „Einzug prüfen, eine Mahnung ist hier nicht vorgesehen“, der SEPA-Lauf zieht die Forderung nicht erneut ein (und weist die übersprungene Position aus) und die Mahnungsmail bittet um Überweisung, auch wenn das Lastschriftmandat weiterhin aktiv ist. Wird der Versand einer Mahnung zurückgenommen, steht die Forderung wieder für den Einzug bereit."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Betreff: Bitte prüfen: deine bei {{organization.name}} gespeicherten Daten
|
||||
|
||||
Liebes Mitglied,
|
||||
|
||||
folgende Daten haben wir über dich gespeichert. Wir bitten dich, diese einmal zu überprüfen und uns Änderungen ggf. mitzuteilen.
|
||||
|
||||
{{data.sheet}}
|
||||
{{data.iban_hint}}
|
||||
|
||||
Bitte gib bei Überweisungen an uns immer deine Mitgliedsnummer und/oder deinen vollen Namen (Vorname und Nachname) als Verwendungszweck an – dann können wir deine Zahlung sofort zuordnen:
|
||||
|
||||
{{payment.reference}}
|
||||
|
||||
Stimmt etwas nicht oder fehlt eine Angabe? Dann antworte einfach auf diese Mail oder schreib uns an {{organization.email}}, dann pflegen wir die Änderung ein. Wenn alles passt, musst du nichts weiter tun.
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -0,0 +1,21 @@
|
||||
Betreff: {{reminder.name}} – {{claim.title}}
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
|
||||
zu der Forderung „{{claim.title}}“ ist noch ein Betrag offen.
|
||||
|
||||
Offener Betrag: {{claim.balance}} Euro
|
||||
Ursprünglich fällig: {{claim.due_date}}
|
||||
Neue Zahlungsfrist: {{reminder.payment_deadline}}
|
||||
{{reminder.items}}
|
||||
{{reminder.hint}}
|
||||
|
||||
Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:
|
||||
IBAN: {{organization.iban}}
|
||||
BIC: {{organization.bic}}
|
||||
Verwendungszweck: {{payment.reference}}
|
||||
|
||||
Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, betrachte sie bitte als gegenstandslos.
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -0,0 +1,16 @@
|
||||
Betreff: SEPA-Lastschrift {{organization.name}} – {{debit.collection_date}}
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
|
||||
hiermit informieren wir dich, dass wir den folgenden Betrag per SEPA-Lastschrift einziehen werden:
|
||||
|
||||
Betrag: {{debit.amount}} Euro
|
||||
Einzugsdatum: {{debit.collection_date}}
|
||||
Verwendungszweck: {{debit.purpose}}
|
||||
Mandatsreferenz: {{debit.mandate_reference}}
|
||||
Gläubiger-Identifikationsnummer: {{organization.creditor_id}}
|
||||
|
||||
Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -0,0 +1,25 @@
|
||||
Betreff: Willkommen im Club, {{member.first_name}} – deine Mitgliedschaft und der erste Beitrag
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
|
||||
herzlich willkommen bei {{organization.name}}! Der Vorstand hat deine Aufnahme beschlossen: Ab dem {{membership.start_date}} bist du offiziell Mitglied. Wir freuen uns sehr, dich an Bord zu haben.
|
||||
|
||||
Deine Mitgliedsnummer lautet {{member.number}}. Damit ordnen wir Zahlungen und Rückfragen sofort zu – am besten schreibst du sie dir kurz auf.
|
||||
|
||||
Damit auch die Buchhaltung glücklich ist, kommt hier deine erste Rechnung. Deine Zahlweise ist „{{member.payment_frequency}}“, offen sind aktuell:
|
||||
|
||||
{{claims.list}}
|
||||
|
||||
Gesamtbetrag: {{claims.total}} Euro
|
||||
Zahlbar bis: {{claims.first_due_date}}
|
||||
|
||||
{{payment.instructions}}
|
||||
|
||||
Und jetzt zum wichtigen Teil: Der Club lebt von den Leuten, die mitmachen. Komm zu den Clubabenden vorbei, bring deine Projekte mit, frag Löcher in Bäuche und teile, was du weißt. Es gibt keine dummen Fragen, nur ungelötete Ideen.
|
||||
|
||||
Wenn etwas unklar ist, antworte einfach auf diese Mail oder schreib uns an {{organization.email}} – wir helfen gerne weiter.
|
||||
|
||||
Viel Spaß, viel Neugier und happy hacking!
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -153,6 +153,29 @@ def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
||||
return "open"
|
||||
|
||||
|
||||
def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
|
||||
return sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in reminder.get("items") or []),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
def claim_was_dunned(data: ContributionData, claim: dict[str, Any]) -> bool:
|
||||
"""True once a reminder for this claim has actually gone out. Such a claim has
|
||||
left the direct-debit track: the collection failed (or never happened), the member
|
||||
was asked in writing to pay it, and the money is expected as a transfer by the
|
||||
stated deadline. Quietly collecting it a second time is exactly what the dunning
|
||||
letter says will not happen, so both the SEPA run and the housekeeper ask this
|
||||
before treating a claim as one the mandate still covers."""
|
||||
claim_id = str(claim.get("claim_id", ""))
|
||||
if not claim_id:
|
||||
return False
|
||||
return any(
|
||||
str(reminder.get("claim_id", "")) == claim_id and str(reminder.get("status", "")) == "sent"
|
||||
for reminder in data.reminders
|
||||
)
|
||||
|
||||
|
||||
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
|
||||
valid_from = str(override.get("valid_from", ""))
|
||||
valid_until = str(override.get("valid_until", "") or "")
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SUBJECT_HEADER = "Betreff:"
|
||||
BUILTIN_TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "assets" / "mail_templates"
|
||||
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
|
||||
# A repeat block either wraps a single line ("{{#claims}}- {{claim.title}}{{/claims}}")
|
||||
# or spans several lines with the markers on their own lines; both render one copy of
|
||||
# the block per entry.
|
||||
# Matches a single repeat marker, opening or closing. The placeholder pattern
|
||||
# deliberately does not, so unbalanced markers need a check of their own -- they
|
||||
# would otherwise survive validation and end up verbatim in a sent mail.
|
||||
MARKER_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
|
||||
BLOCK_PATTERN = re.compile(
|
||||
r"\{\{\s*#\s*([a-z][a-z0-9_.]*)\s*\}\}[ \t]*\n?(.*?)\{\{\s*/\s*\1\s*\}\}[ \t]*(\n?)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class MailTemplateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailTemplate:
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailTemplateBlock:
|
||||
name: str
|
||||
description: str
|
||||
# Placeholder name -> short explanation, valid inside this block only.
|
||||
placeholders: tuple[tuple[str, str], ...]
|
||||
# The line the inserted block starts out with. It makes the inserted snippet a
|
||||
# working block that renders real content -- the board edits this line rather
|
||||
# than having to write the whole construct by hand.
|
||||
sample: str
|
||||
|
||||
@property
|
||||
def snippet(self) -> str:
|
||||
return f"{{{{#{self.name}}}}}\n{self.sample}\n{{{{/{self.name}}}}}\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlaceholderEntry:
|
||||
"""One row of the options dialog's placeholder chooser: what it reads as, and
|
||||
what double-clicking it puts into the template."""
|
||||
|
||||
label: str
|
||||
snippet: str
|
||||
description: str
|
||||
indented: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailTemplateSpec:
|
||||
key: str
|
||||
label: str
|
||||
filename: str
|
||||
description: str
|
||||
# Placeholder name -> short explanation, shown as editing help in the options
|
||||
# dialog. The mail services must supply exactly these keys, so this doubles as
|
||||
# the contract a stored template is validated against.
|
||||
placeholders: tuple[tuple[str, str], ...]
|
||||
blocks: tuple[MailTemplateBlock, ...] = ()
|
||||
|
||||
|
||||
BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
|
||||
("member.first_name", "Vorname"),
|
||||
("member.last_name", "Nachname"),
|
||||
("member.full_name", "Vor- und Nachname"),
|
||||
("member.nickname", "Nickname"),
|
||||
("member.number", "Mitgliedsnummer"),
|
||||
("member.email", "E-Mail-Adresse"),
|
||||
("member.status", "Mitgliedsstatus"),
|
||||
("member.accepted_at", "Datum des Aufnahmebeschlusses"),
|
||||
("member.started_at", "Mitglied seit"),
|
||||
("member.application_date", "Antragsdatum"),
|
||||
("member.payment_frequency", "Zahlweise"),
|
||||
("member.address_line", "Anschrift in einer Zeile"),
|
||||
("member.iban", "IBAN des Mitglieds"),
|
||||
("member.mandate_reference", "Mandatsreferenz"),
|
||||
("member.mandate_active", "Lastschriftmandat aktiv (Ja/Nein)"),
|
||||
("organization.name", "Vereinsname"),
|
||||
("organization.address_line", "Anschrift des Vereins"),
|
||||
("organization.email", "E-Mail-Adresse des Vereins"),
|
||||
("organization.website", "Website des Vereins"),
|
||||
("organization.iban", "IBAN des Vereins"),
|
||||
("organization.bic", "BIC des Vereins"),
|
||||
("organization.creditor_id", "Gläubiger-Identifikationsnummer"),
|
||||
("current_date", "Heutiges Datum"),
|
||||
("signature", "Signatur aus dem Versandfenster"),
|
||||
)
|
||||
|
||||
# The fields a member can check but which no other mail needs, so they stay out of
|
||||
# BASE_PLACEHOLDERS: the data-review mail is the one that prints the whole record.
|
||||
MEMBER_RECORD_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
|
||||
("member.birth_date", "Geburtsdatum"),
|
||||
("member.phone", "Telefonnummer"),
|
||||
("member.street", "Straße und Hausnummer"),
|
||||
("member.address_addition", "Adresszusatz"),
|
||||
("member.postal_code", "Postleitzahl"),
|
||||
("member.city", "Ort"),
|
||||
("member.country", "Land"),
|
||||
("member.account_holder", "Kontoinhaber"),
|
||||
("member.iban_masked", "IBAN, bis auf die letzten vier Stellen maskiert"),
|
||||
("member.mandate_signed_at", "Datum der Mandatserteilung"),
|
||||
)
|
||||
|
||||
CLAIM_ITEM_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
|
||||
("claim.title", "Bezeichnung der Forderung"),
|
||||
("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"),
|
||||
("claim.due_date", "Fälligkeitsdatum"),
|
||||
("claim.amount", "Gesamtbetrag der Forderung"),
|
||||
("claim.balance", "Noch offener Betrag"),
|
||||
)
|
||||
|
||||
MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
|
||||
MailTemplateSpec(
|
||||
key="welcome",
|
||||
label="Willkommen & Erstrechnung",
|
||||
filename="willkommen.txt",
|
||||
description=(
|
||||
"Begrüßung neuer Mitglieder mit Mitgliedsnummer, Beginn der Mitgliedschaft "
|
||||
"und den ersten offenen Forderungen (Aufnahmegebühr und erster Beitrag)."
|
||||
),
|
||||
placeholders=BASE_PLACEHOLDERS
|
||||
+ (
|
||||
(
|
||||
"membership.start_date",
|
||||
"Beginn der Mitgliedschaft; ist noch keiner erfasst, der 1. des auf den "
|
||||
"Aufnahmebeschluss folgenden Monats",
|
||||
),
|
||||
("claims.list", "Alle ausgewählten Forderungen als Liste, eine je Zeile"),
|
||||
("claims.total", "Summe der ausgewählten Forderungen"),
|
||||
("claims.count", "Anzahl der ausgewählten Forderungen"),
|
||||
("claims.first_due_date", "Früheste Fälligkeit der ausgewählten Forderungen"),
|
||||
("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
|
||||
("payment.reference", "Vorgeschlagener Verwendungszweck"),
|
||||
),
|
||||
blocks=(
|
||||
MailTemplateBlock(
|
||||
name="claims",
|
||||
description="Wiederholt sich je ausgewählter Forderung",
|
||||
placeholders=CLAIM_ITEM_PLACEHOLDERS,
|
||||
sample="{{claim.description}} (fällig {{claim.due_date}}): {{claim.balance}} Euro",
|
||||
),
|
||||
),
|
||||
),
|
||||
MailTemplateSpec(
|
||||
key="reminder",
|
||||
label="Mahnung",
|
||||
filename="mahnung.txt",
|
||||
description=(
|
||||
"Begleitmail zu einer Mahnung bzw. Zahlungserinnerung. Wird beim Versand "
|
||||
"mit der jeweiligen Mahnstufe, Frist und den Mahnpositionen gefüllt."
|
||||
),
|
||||
placeholders=BASE_PLACEHOLDERS
|
||||
+ CLAIM_ITEM_PLACEHOLDERS
|
||||
+ (
|
||||
("reminder.name", "Bezeichnung der Mahnstufe"),
|
||||
("reminder.level", "Nummer der Mahnstufe"),
|
||||
("reminder.payment_deadline", "Neue Zahlungsfrist"),
|
||||
("reminder.detail", "Freitext-Hinweis der Mahnung"),
|
||||
("reminder.hint", "Hinweiszeile, nur gefüllt wenn ein Freitext hinterlegt ist"),
|
||||
("reminder.items", "Mahnpositionen als Liste, eine je Zeile"),
|
||||
("reminder.fee_total", "Summe der Mahnpositionen"),
|
||||
("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
|
||||
("payment.reference", "Vorgeschlagener Verwendungszweck"),
|
||||
),
|
||||
blocks=(
|
||||
MailTemplateBlock(
|
||||
name="reminder.items",
|
||||
description="Wiederholt sich je Mahnposition",
|
||||
placeholders=(
|
||||
("item.description", "Bezeichnung der Position"),
|
||||
("item.amount", "Betrag der Position"),
|
||||
),
|
||||
sample="{{item.description}}: {{item.amount}} Euro",
|
||||
),
|
||||
),
|
||||
),
|
||||
MailTemplateSpec(
|
||||
key="sepa",
|
||||
label="SEPA-Vorabinformation",
|
||||
filename="sepa-info.txt",
|
||||
description=(
|
||||
"Vorabinformation zu einem geplanten Lastschrifteinzug mit Betrag, "
|
||||
"Einzugsdatum und Mandatsdaten."
|
||||
),
|
||||
placeholders=BASE_PLACEHOLDERS
|
||||
+ (
|
||||
("debit.amount", "Einzuziehender Betrag"),
|
||||
("debit.collection_date", "Einzugsdatum"),
|
||||
("debit.purpose", "Verwendungszweck der Lastschrift"),
|
||||
("debit.mandate_reference", "Mandatsreferenz"),
|
||||
),
|
||||
),
|
||||
MailTemplateSpec(
|
||||
key="data_review",
|
||||
label="Datenüberprüfung",
|
||||
filename="datenpruefung.txt",
|
||||
description=(
|
||||
"Bitte an ein Mitglied, die über es gespeicherten Daten zu prüfen. Wird an "
|
||||
"beliebig viele Mitglieder auf einmal verschickt und listet je Empfänger "
|
||||
"dessen eigenen Datensatz auf."
|
||||
),
|
||||
placeholders=BASE_PLACEHOLDERS
|
||||
+ MEMBER_RECORD_PLACEHOLDERS
|
||||
+ (
|
||||
("data.sheet", "Alle gespeicherten Daten als Liste, eine Zeile je Feld"),
|
||||
("data.count", "Anzahl der aufgeführten Felder"),
|
||||
(
|
||||
"data.iban_hint",
|
||||
"Hinweis zur verkürzten IBAN, nur gefüllt wenn Bankdaten aufgeführt werden",
|
||||
),
|
||||
(
|
||||
"payment.reference",
|
||||
"Vorgeschlagener Verwendungszweck aus Mitgliedsnummer und vollem Namen",
|
||||
),
|
||||
),
|
||||
blocks=(
|
||||
MailTemplateBlock(
|
||||
name="data",
|
||||
description="Wiederholt sich je gespeichertem Datenfeld",
|
||||
placeholders=(
|
||||
("field.label", "Bezeichnung des Feldes"),
|
||||
("field.value", "Gespeicherter Wert"),
|
||||
),
|
||||
sample="{{field.label}}: {{field.value}}",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def placeholder_entries(key: str) -> list[PlaceholderEntry]:
|
||||
"""Rows for the placeholder chooser. Repeat blocks contribute a complete,
|
||||
ready-to-edit block -- inserting one has to leave a working template behind,
|
||||
not a description of one."""
|
||||
spec = template_spec(key)
|
||||
entries = [
|
||||
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description)
|
||||
for name, description in spec.placeholders
|
||||
]
|
||||
for block in spec.blocks:
|
||||
entries.append(
|
||||
PlaceholderEntry(
|
||||
f"{{{{#{block.name}}}}} … {{{{/{block.name}}}}}", block.snippet, block.description
|
||||
)
|
||||
)
|
||||
entries.extend(
|
||||
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description, indented=True)
|
||||
for name, description in block.placeholders
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def template_spec(key: str) -> MailTemplateSpec:
|
||||
for spec in MAIL_TEMPLATES:
|
||||
if spec.key == key:
|
||||
return spec
|
||||
raise MailTemplateError(f"Unbekannte E-Mail-Vorlage: {key}")
|
||||
|
||||
|
||||
def default_mail_template(key: str) -> MailTemplate:
|
||||
spec = template_spec(key)
|
||||
path = BUILTIN_TEMPLATE_ROOT / spec.filename
|
||||
try:
|
||||
return parse_mail_template(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise MailTemplateError(
|
||||
f"Die mitgelieferte Vorlage „{spec.label}“ konnte nicht gelesen werden: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_mail_template(text: str) -> MailTemplate:
|
||||
"""Templates are plain text files whose first line carries the subject
|
||||
("Betreff: ..."), followed by a blank line and the message body -- readable and
|
||||
editable with any text editor, just like the rest of the store."""
|
||||
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
subject = ""
|
||||
if lines and lines[0].strip().casefold().startswith(SUBJECT_HEADER.casefold()):
|
||||
subject = lines[0].split(":", 1)[1].strip()
|
||||
lines = lines[1:]
|
||||
if lines and not lines[0].strip():
|
||||
lines = lines[1:]
|
||||
return MailTemplate(subject, "\n".join(lines).strip("\n"))
|
||||
|
||||
|
||||
def serialize_mail_template(subject: str, body: str) -> str:
|
||||
normalized = body.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
||||
return f"{SUBJECT_HEADER} {subject.strip()}\n\n{normalized}\n"
|
||||
|
||||
|
||||
def render_mail_template(
|
||||
template: MailTemplate,
|
||||
values: dict[str, str],
|
||||
repeats: dict[str, list[dict[str, str]]] | None = None,
|
||||
) -> MailTemplate:
|
||||
subject = _render_line(template.subject, values)
|
||||
body = _render_blocks(template.body, values, repeats or {})
|
||||
return MailTemplate(subject.strip(), _render_text(body, values))
|
||||
|
||||
|
||||
def validate_block_markers(key: str, subject: str, body: str) -> None:
|
||||
"""Checks that every {{#block}} is closed by its own {{/block}} and that no
|
||||
stray marker is left over. An unbalanced marker is not a placeholder, so it
|
||||
renders as itself -- the recipient would read "{{#claims}}" in their mail."""
|
||||
spec = template_spec(key)
|
||||
known = {block.name.casefold(): block.name for block in spec.blocks}
|
||||
for match in MARKER_PATTERN.finditer(subject):
|
||||
raise MailTemplateError(
|
||||
f"Wiederholungsblöcke sind im Betreff nicht möglich: {match.group(0)}"
|
||||
)
|
||||
open_marker: tuple[str, str] | None = None
|
||||
for match in MARKER_PATTERN.finditer(body):
|
||||
kind, name = match.group(1), match.group(2)
|
||||
if name.casefold() not in known:
|
||||
raise MailTemplateError(
|
||||
f"Unbekannter Wiederholungsblock: {match.group(0)}. Verfügbar: "
|
||||
+ (", ".join(f"{{{{#{value}}}}}" for value in known.values()) or "keine")
|
||||
)
|
||||
if kind == "#":
|
||||
if open_marker is not None:
|
||||
raise MailTemplateError(
|
||||
f"Wiederholungsblöcke können nicht ineinander verschachtelt werden: "
|
||||
f"{{{{#{open_marker[1]}}}}} ist noch offen, als {match.group(0)} beginnt."
|
||||
)
|
||||
open_marker = (kind, name)
|
||||
continue
|
||||
if open_marker is None:
|
||||
raise MailTemplateError(
|
||||
f"{match.group(0)} steht ohne zugehöriges {{{{#{name}}}}} in der Vorlage."
|
||||
)
|
||||
if open_marker[1].casefold() != name.casefold():
|
||||
raise MailTemplateError(
|
||||
f"{{{{#{open_marker[1]}}}}} wird durch {match.group(0)} geschlossen. "
|
||||
f"Erwartet wird {{{{/{open_marker[1]}}}}}."
|
||||
)
|
||||
open_marker = None
|
||||
if open_marker is not None:
|
||||
raise MailTemplateError(
|
||||
f"{{{{#{open_marker[1]}}}}} wird nicht geschlossen. Es fehlt "
|
||||
f"{{{{/{open_marker[1]}}}}}."
|
||||
)
|
||||
|
||||
|
||||
def validate_mail_template(key: str, subject: str, body: str) -> None:
|
||||
"""Rejects placeholders the mail service will never supply, so a typo surfaces
|
||||
while editing the template instead of during a send run."""
|
||||
spec = template_spec(key)
|
||||
validate_block_markers(key, subject, body)
|
||||
known = {name for name, _help in spec.placeholders}
|
||||
block_names = {block.name for block in spec.blocks}
|
||||
unknown: set[str] = set()
|
||||
for block in spec.blocks:
|
||||
block_known = known | {name for name, _help in block.placeholders}
|
||||
for match in _blocks_of(body, block.name):
|
||||
unknown.update(name for name in _placeholder_names(match) if name not in block_known)
|
||||
remaining = body
|
||||
for block_name in block_names:
|
||||
remaining = _strip_blocks(remaining, block_name)
|
||||
unknown.update(name for name in _placeholder_names(remaining) if name not in known)
|
||||
unknown.update(name for name in _placeholder_names(subject) if name not in known)
|
||||
if unknown:
|
||||
raise MailTemplateError(
|
||||
"Unbekannte Platzhalter in der Vorlage: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
if not subject.strip():
|
||||
raise MailTemplateError("Die Vorlage benötigt einen Betreff.")
|
||||
if not body.strip():
|
||||
raise MailTemplateError("Die Vorlage benötigt einen Text.")
|
||||
|
||||
|
||||
def _placeholder_names(text: str) -> set[str]:
|
||||
return {match.group(1) for match in PLACEHOLDER_PATTERN.finditer(text)}
|
||||
|
||||
|
||||
def _blocks_of(text: str, name: str) -> list[str]:
|
||||
return [
|
||||
match.group(2)
|
||||
for match in BLOCK_PATTERN.finditer(text)
|
||||
if match.group(1).casefold() == name.casefold()
|
||||
]
|
||||
|
||||
|
||||
def _strip_blocks(text: str, name: str) -> str:
|
||||
return BLOCK_PATTERN.sub(
|
||||
lambda match: "" if match.group(1).casefold() == name.casefold() else match.group(0), text
|
||||
)
|
||||
|
||||
|
||||
def _render_blocks(
|
||||
body: str, values: dict[str, str], repeats: dict[str, list[dict[str, str]]]
|
||||
) -> str:
|
||||
def expand(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
entries = next(
|
||||
(rows for key, rows in repeats.items() if key.casefold() == name.casefold()), None
|
||||
)
|
||||
if entries is None:
|
||||
return match.group(0)
|
||||
block = match.group(2)
|
||||
if block.endswith("\n"):
|
||||
block = block[:-1]
|
||||
rendered = [_render_text(block, {**values, **entry}) for entry in entries]
|
||||
if not rendered:
|
||||
return ""
|
||||
return "\n".join(rendered) + match.group(3)
|
||||
|
||||
return BLOCK_PATTERN.sub(expand, body)
|
||||
|
||||
|
||||
def _render_text(text: str, values: dict[str, str]) -> str:
|
||||
# A line that holds nothing but placeholders which all render empty (an optional
|
||||
# hint, an empty list) is dropped instead of leaving a stray blank line behind.
|
||||
rendered = []
|
||||
for line in text.split("\n"):
|
||||
replaced = _render_line(line, values)
|
||||
if replaced.strip() or not line.strip() or not _placeholder_names(line):
|
||||
rendered.append(replaced)
|
||||
continue
|
||||
if PLACEHOLDER_PATTERN.sub("", line).strip():
|
||||
rendered.append(replaced)
|
||||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def _render_line(line: str, values: dict[str, str]) -> str:
|
||||
return PLACEHOLDER_PATTERN.sub(
|
||||
lambda match: values.get(match.group(1), match.group(0)), line
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import (
|
||||
MEMBERSHIP_STATUS_LABELS,
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
Member,
|
||||
)
|
||||
|
||||
|
||||
def base_placeholder_values(
|
||||
member: Member,
|
||||
organization: dict | None = None,
|
||||
*,
|
||||
created_at: datetime | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Member and organization placeholders shared by document templates and mail
|
||||
templates, so the same `{{member.*}}`/`{{organization.*}}` names mean the same
|
||||
thing in a dunning letter and in the e-mail that carries it."""
|
||||
organization = organization if isinstance(organization, dict) else {}
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
organization_address = " ".join(
|
||||
part
|
||||
for part in (
|
||||
str(organization.get("street", "")),
|
||||
str(organization.get("postal_code", "")),
|
||||
str(organization.get("city", "")),
|
||||
)
|
||||
if part
|
||||
)
|
||||
return {
|
||||
"current_date": format_date_for_display(timestamp.date().isoformat()),
|
||||
"current_datetime": timestamp.strftime("%d.%m.%Y %H:%M"),
|
||||
"member.id": member.member_id,
|
||||
"member.number": member.member_number,
|
||||
"member.first_name": member.first_name,
|
||||
"member.last_name": member.last_name,
|
||||
"member.nickname": member.nickname,
|
||||
"member.full_name": member.display_name,
|
||||
"member.email": member.email,
|
||||
"member.phone": member.phone,
|
||||
"member.birth_date": format_date_for_display(member.birth_date),
|
||||
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
"member.accepted_at": format_date_for_display(member.accepted_at),
|
||||
"member.started_at": format_date_for_display(member.membership_started_at),
|
||||
"member.ended_at": format_date_for_display(member.membership_ended_at),
|
||||
"member.application_date": format_date_for_display(member.application_date),
|
||||
"member.payment_frequency": PAYMENT_FREQUENCY_LABELS.get(
|
||||
member.payment_frequency, member.payment_frequency
|
||||
).capitalize(),
|
||||
"member.street": member.street,
|
||||
"member.address_addition": member.address_addition,
|
||||
"member.postal_code": member.postal_code,
|
||||
"member.city": member.city,
|
||||
"member.country": member.country,
|
||||
"member.address_line": " ".join(
|
||||
part for part in (member.street, member.postal_code, member.city) if part
|
||||
),
|
||||
"member.account_holder": member.account_holder,
|
||||
"member.iban": member.iban,
|
||||
"member.bic": member.bic,
|
||||
"member.mandate_reference": member.mandate_reference,
|
||||
"member.mandate_signed_at": format_date_for_display(member.mandate_signed_at),
|
||||
"member.mandate_revoked_at": format_date_for_display(member.mandate_revoked_at),
|
||||
"member.mandate_active": "Ja" if member.mandate_active else "Nein",
|
||||
"organization.name": str(organization.get("name", "")),
|
||||
"organization.street": str(organization.get("street", "")),
|
||||
"organization.postal_code": str(organization.get("postal_code", "")),
|
||||
"organization.city": str(organization.get("city", "")),
|
||||
"organization.country": str(organization.get("country", "")),
|
||||
"organization.address_line": organization_address,
|
||||
"organization.email": str(organization.get("email", "")),
|
||||
"organization.phone": str(organization.get("phone", "")),
|
||||
"organization.website": str(organization.get("website", "")),
|
||||
"organization.iban": str(organization.get("iban", "")),
|
||||
"organization.bic": str(organization.get("bic", "")),
|
||||
"organization.creditor_id": str(organization.get("creditor_id", "")),
|
||||
}
|
||||
@@ -8,6 +8,8 @@ ORDER = 20
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
birth_date = validate_birth_date(member.birth_date, today=context.today)
|
||||
except DateValidationError:
|
||||
|
||||
@@ -8,6 +8,8 @@ ORDER = 30
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit")
|
||||
except DateValidationError:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
||||
from ccma.domain.contributions import claim_balance, claim_status, claim_was_dunned, money_text
|
||||
from ccma.rules.api import RuleAction, RuleContext, task
|
||||
|
||||
RULE_ID = "reminder-due"
|
||||
@@ -18,12 +18,12 @@ DEFAULT_POLICY = {
|
||||
|
||||
def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
|
||||
grace period, then either the postal dunning escalation or -- for members paying by
|
||||
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
|
||||
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
|
||||
escalation applies (dunning hold active, or between reminder levels), so there is
|
||||
always exactly one finding for an overdue claim, never both an overdue notice and a
|
||||
reminder notice at once."""
|
||||
grace period, then either the postal dunning escalation or -- for a claim the
|
||||
member's mandate still covers, where sending a dunning letter makes no sense -- a
|
||||
distinct "check the direct debit" notice. Falls back to the plain overdue notice
|
||||
whenever no escalation applies (dunning hold active, or between reminder levels),
|
||||
so there is always exactly one finding for an overdue claim, never both an overdue
|
||||
notice and a reminder notice at once."""
|
||||
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
||||
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
||||
grace_days = int(policy.get("grace_days_after_due", 7))
|
||||
@@ -48,7 +48,10 @@ def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||
|
||||
escalation = None
|
||||
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
||||
if context.member.mandate_active:
|
||||
# A claim that was already dunned is no longer collected by direct debit --
|
||||
# the member was asked to transfer it, so it continues in the ordinary
|
||||
# dunning sequence even though the mandate itself is still active.
|
||||
if context.member.mandate_active and not claim_was_dunned(context.contributions, claim):
|
||||
escalation = _sepa_action(context, claim, claim_id, days_overdue)
|
||||
else:
|
||||
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
||||
@@ -105,8 +108,14 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy)
|
||||
return None
|
||||
level = int(next_level.get("level", 0))
|
||||
trigger_date = _trigger_date(claim, sent_levels, level, policy)
|
||||
if not trigger_date or context.today < trigger_date:
|
||||
if not trigger_date:
|
||||
return None
|
||||
if context.today < trigger_date:
|
||||
# A reminder for the previous level was already sent and its own payment
|
||||
# deadline hasn't expired yet -- this is being handled, not neglected, so it
|
||||
# must not show up as the same "error" severity as a plain unaddressed overdue
|
||||
# claim (that fallback is what the caller uses when this returns None).
|
||||
return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
|
||||
draft_exists = any(
|
||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||
for item in reminders
|
||||
@@ -134,6 +143,28 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy)
|
||||
)
|
||||
|
||||
|
||||
def _awaiting_deadline_action(
|
||||
context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
|
||||
) -> RuleAction:
|
||||
previous_level = level - 1
|
||||
previous = sent_levels.get(previous_level) or {}
|
||||
name = str(previous.get("name") or f"Mahnstufe {previous_level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
|
||||
severity="info",
|
||||
code="reminder_awaiting_deadline",
|
||||
title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||||
),
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||
if level == 1:
|
||||
try:
|
||||
|
||||
@@ -21,9 +21,11 @@ from ccma.domain.contributions import (
|
||||
claim_status,
|
||||
claim_total,
|
||||
money_text,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
|
||||
from ccma.domain.models import Member
|
||||
from ccma.domain.placeholders import base_placeholder_values
|
||||
from ccma.storage.repository import MemberRepository
|
||||
|
||||
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
|
||||
@@ -97,6 +99,7 @@ class DocumentService:
|
||||
if not template_path.is_file() or template_path.suffix.casefold() not in SUPPORTED_TEMPLATE_SUFFIXES:
|
||||
raise DocumentError("Das ausgewählte OpenDocument-Template ist nicht verfügbar.")
|
||||
|
||||
self.repository.assert_writable()
|
||||
member = self.repository.get_member(member_id)
|
||||
data = None
|
||||
claim = None
|
||||
@@ -181,65 +184,14 @@ def _template_values(
|
||||
reminder: dict | None = None,
|
||||
organization: dict | None = None,
|
||||
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
|
||||
organization = organization or {}
|
||||
created_at = datetime.now().astimezone()
|
||||
created_date = format_date_for_display(created_at.date().isoformat())
|
||||
created_timestamp = created_at.strftime("%d.%m.%Y %H:%M")
|
||||
organization_address = " ".join(
|
||||
part
|
||||
for part in (
|
||||
str(organization.get("street", "")),
|
||||
str(organization.get("postal_code", "")),
|
||||
str(organization.get("city", "")),
|
||||
)
|
||||
if part
|
||||
values = base_placeholder_values(member, organization, created_at=created_at)
|
||||
values.update(
|
||||
{
|
||||
"document.created_date": values["current_date"],
|
||||
"document.created_at": values["current_datetime"],
|
||||
}
|
||||
)
|
||||
values = {
|
||||
"document.created_date": created_date,
|
||||
"document.created_at": created_timestamp,
|
||||
"current_date": created_date,
|
||||
"current_datetime": created_timestamp,
|
||||
"member.id": member.member_id,
|
||||
"member.number": member.member_number,
|
||||
"member.first_name": member.first_name,
|
||||
"member.last_name": member.last_name,
|
||||
"member.nickname": member.nickname,
|
||||
"member.full_name": member.display_name,
|
||||
"member.email": member.email,
|
||||
"member.phone": member.phone,
|
||||
"member.birth_date": format_date_for_display(member.birth_date),
|
||||
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
"member.accepted_at": format_date_for_display(member.accepted_at),
|
||||
"member.started_at": format_date_for_display(member.membership_started_at),
|
||||
"member.ended_at": format_date_for_display(member.membership_ended_at),
|
||||
"member.street": member.street,
|
||||
"member.address_addition": member.address_addition,
|
||||
"member.postal_code": member.postal_code,
|
||||
"member.city": member.city,
|
||||
"member.country": member.country,
|
||||
"member.address_line": " ".join(
|
||||
part for part in (member.street, member.postal_code, member.city) if part
|
||||
),
|
||||
"member.account_holder": member.account_holder,
|
||||
"member.iban": member.iban,
|
||||
"member.bic": member.bic,
|
||||
"member.mandate_reference": member.mandate_reference,
|
||||
"member.mandate_signed_at": format_date_for_display(member.mandate_signed_at),
|
||||
"member.mandate_revoked_at": format_date_for_display(member.mandate_revoked_at),
|
||||
"member.mandate_active": "Ja" if member.mandate_active else "Nein",
|
||||
"organization.name": str(organization.get("name", "")),
|
||||
"organization.street": str(organization.get("street", "")),
|
||||
"organization.postal_code": str(organization.get("postal_code", "")),
|
||||
"organization.city": str(organization.get("city", "")),
|
||||
"organization.country": str(organization.get("country", "")),
|
||||
"organization.address_line": organization_address,
|
||||
"organization.email": str(organization.get("email", "")),
|
||||
"organization.phone": str(organization.get("phone", "")),
|
||||
"organization.website": str(organization.get("website", "")),
|
||||
"organization.iban": str(organization.get("iban", "")),
|
||||
"organization.bic": str(organization.get("bic", "")),
|
||||
"organization.creditor_id": str(organization.get("creditor_id", "")),
|
||||
}
|
||||
repeats: dict[str, list[dict[str, str]]] = {}
|
||||
if claim is not None and data is not None:
|
||||
claim_id = str(claim.get("claim_id", ""))
|
||||
@@ -293,6 +245,10 @@ def _template_values(
|
||||
channel_labels = {"email": "E-Mail", "letter": "Brief", "personal": "Persönlich"}
|
||||
status = str(reminder.get("status", "draft"))
|
||||
channel = str(reminder.get("channel", ""))
|
||||
reminder_items = reminder.get("items") or []
|
||||
item_lines = [
|
||||
f"{item.get('description', '')}: {item.get('amount', '0.00')} EUR" for item in reminder_items
|
||||
]
|
||||
values.update(
|
||||
{
|
||||
"reminder.id": str(reminder.get("reminder_id", "")),
|
||||
@@ -305,11 +261,19 @@ def _template_values(
|
||||
str(reminder.get("payment_deadline") or "")
|
||||
),
|
||||
"reminder.payment_deadline_days": str(reminder.get("payment_deadline_days", "")),
|
||||
"reminder.fee": f"{reminder.get('fee', '0.00')} EUR",
|
||||
"reminder.fee": f"{money_text(reminder_items_total(reminder))} EUR",
|
||||
"reminder.items": "; ".join(item_lines),
|
||||
"reminder.detail": str(reminder.get("detail", "")),
|
||||
"reminder.channel": channel_labels.get(channel, channel),
|
||||
}
|
||||
)
|
||||
repeats["reminder.items"] = [
|
||||
{
|
||||
"item.description": str(item.get("description", "")),
|
||||
"item.amount": str(item.get("amount", "")),
|
||||
}
|
||||
for item in reminder_items
|
||||
]
|
||||
return values, repeats
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -67,9 +67,26 @@ class Housekeeper:
|
||||
today: date | None = None,
|
||||
*,
|
||||
member_delay: float = 0.0,
|
||||
member_id: str | None = None,
|
||||
retroactive_claims: bool | None = None,
|
||||
) -> list[HousekeeperFinding]:
|
||||
"""Run all rules. With `member_id`, only that member is (re-)evaluated --
|
||||
asset checks are skipped and every other member's tasks are left untouched
|
||||
(their rules never ran this pass, so they fall outside `successful_scopes`
|
||||
and `_resolve_stale_tasks` can't touch them). `retroactive_claims`, if given,
|
||||
overrides the configured setting for just this run without changing it
|
||||
permanently -- e.g. to recompute one member's full claim history on demand
|
||||
after a contribution change, without retroactively billing everyone else."""
|
||||
# The housekeeper exists to write: it refreshes tasks, creates claims and
|
||||
# updates member records. On a read-only store it must not even take its lock.
|
||||
self.repository.assert_writable()
|
||||
current_date = today or date.today()
|
||||
delay = _non_negative_delay(member_delay)
|
||||
effective_settings = (
|
||||
self.settings
|
||||
if retroactive_claims is None
|
||||
else replace(self.settings, retroactive_claims=retroactive_claims)
|
||||
)
|
||||
with _exclusive_lock(self.lock_path):
|
||||
original = self._load_state()
|
||||
working = copy.deepcopy(original)
|
||||
@@ -78,32 +95,39 @@ class Housekeeper:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
items = _items_by_key(working)
|
||||
successful_scopes: set[tuple[str, str]] = set()
|
||||
member_ids = set(self.repository.list_member_ids())
|
||||
all_member_ids = set(self.repository.list_member_ids())
|
||||
asset_ids = set(self.repository.list_asset_ids())
|
||||
_remove_orphaned_member_items(items, member_ids)
|
||||
_remove_orphaned_member_items(items, all_member_ids)
|
||||
_remove_orphaned_asset_items(items, asset_ids)
|
||||
|
||||
if member_id is not None:
|
||||
if member_id not in all_member_ids:
|
||||
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
|
||||
selected_member_ids = {member_id}
|
||||
else:
|
||||
selected_member_ids = all_member_ids
|
||||
|
||||
rules = load_rules(self.repository.root)
|
||||
repository_config = self.repository.get_configuration()
|
||||
for index, member_id in enumerate(sorted(member_ids)):
|
||||
for index, current_member_id in enumerate(sorted(selected_member_ids)):
|
||||
if index and delay:
|
||||
time.sleep(delay)
|
||||
try:
|
||||
member, contributions = self.repository.preflight_member_record(member_id)
|
||||
member, contributions = self.repository.preflight_member_record(current_member_id)
|
||||
except RepositoryError as exc:
|
||||
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
self._refresh_record_integrity_task(items, current_member_id, str(exc), run_id, now)
|
||||
successful_scopes.add(("member-record-check", current_member_id))
|
||||
continue
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
successful_scopes.add(("member-record-check", current_member_id))
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="member",
|
||||
target_id=member_id,
|
||||
warnings=self.repository.member_hash_warnings(member_id),
|
||||
target_id=current_member_id,
|
||||
warnings=self.repository.member_hash_warnings(current_member_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("member-hash-check", member_id))
|
||||
successful_scopes.add(("member-hash-check", current_member_id))
|
||||
for rule in rules:
|
||||
scope = (rule.rule_id, member.member_id)
|
||||
try:
|
||||
@@ -111,7 +135,7 @@ class Housekeeper:
|
||||
member=member,
|
||||
contributions=contributions,
|
||||
today=current_date,
|
||||
settings=self.settings,
|
||||
settings=effective_settings,
|
||||
repository_config=repository_config,
|
||||
)
|
||||
actions = rule.evaluate(context)
|
||||
@@ -141,16 +165,17 @@ class Housekeeper:
|
||||
else:
|
||||
successful_scopes.add(scope)
|
||||
|
||||
for asset_id in sorted(asset_ids):
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="asset",
|
||||
target_id=asset_id,
|
||||
warnings=self.repository.asset_hash_warnings(asset_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("asset-hash-check", asset_id))
|
||||
if member_id is None:
|
||||
for asset_id in sorted(asset_ids):
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="asset",
|
||||
target_id=asset_id,
|
||||
warnings=self.repository.asset_hash_warnings(asset_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("asset-hash-check", asset_id))
|
||||
|
||||
self._resolve_stale_tasks(items, successful_scopes, run_id, now)
|
||||
working.update(
|
||||
@@ -167,6 +192,7 @@ class Housekeeper:
|
||||
return _open_findings(working["items"])
|
||||
|
||||
def delete_task(self, key: str) -> list[HousekeeperFinding]:
|
||||
self.repository.assert_writable()
|
||||
selected_key = key.strip()
|
||||
if not selected_key:
|
||||
raise RepositoryError("Der Task hat keinen gültigen Key.")
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
|
||||
from ccma.domain.mail_templates import (
|
||||
MailTemplate,
|
||||
MailTemplateError,
|
||||
render_mail_template,
|
||||
template_spec,
|
||||
validate_block_markers,
|
||||
)
|
||||
from ccma.domain.models import Member
|
||||
from ccma.domain.placeholders import base_placeholder_values
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def template_values(
|
||||
member: Member,
|
||||
organization: dict | None,
|
||||
*,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> dict[str, str]:
|
||||
values = base_placeholder_values(member, organization, created_at=created_at)
|
||||
# A mail always opens with a salutation, so an empty first name would read as
|
||||
# "Hallo ," -- every other placeholder may legitimately render empty.
|
||||
values["member.first_name"] = member.first_name.strip() or "Mitglied"
|
||||
values["signature"] = signature.strip()
|
||||
return values
|
||||
|
||||
|
||||
def payment_instructions(
|
||||
member: Member,
|
||||
organization: dict | None,
|
||||
*,
|
||||
due_date: str,
|
||||
reference: str,
|
||||
expect_transfer: bool = False,
|
||||
) -> str:
|
||||
"""Ready-made payment paragraph: members with an active mandate are told the
|
||||
money is collected, everyone else gets the club's bank details. `expect_transfer`
|
||||
overrides that for a claim the mandate no longer covers -- a dunned claim is
|
||||
expected as a transfer even though the member still has a mandate."""
|
||||
organization = organization if isinstance(organization, dict) else {}
|
||||
if member.mandate_active and not expect_transfer:
|
||||
mandate = member.mandate_reference.strip()
|
||||
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
|
||||
return (
|
||||
f"Du hast uns ein SEPA-Lastschriftmandat erteilt{mandate_hint} – wir ziehen den "
|
||||
"Betrag fristgerecht von deinem Konto ein. Du musst also nichts weiter tun; "
|
||||
"vor dem Einzug informieren wir dich rechtzeitig per E-Mail."
|
||||
)
|
||||
deadline = f" bis zum {due_date}" if due_date else ""
|
||||
lines = [f"Bitte überweise den Betrag{deadline} auf unser Vereinskonto:", ""]
|
||||
lines.append(f"IBAN: {str(organization.get('iban', '')).strip()}")
|
||||
bic = str(organization.get("bic", "")).strip()
|
||||
if bic:
|
||||
lines.append(f"BIC: {bic}")
|
||||
if reference.strip():
|
||||
lines.append(f"Verwendungszweck: {reference.strip()}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_template(
|
||||
repository: MemberRepository,
|
||||
key: str,
|
||||
values: dict[str, str],
|
||||
repeats: dict[str, list[dict[str, str]]] | None = None,
|
||||
) -> MailTemplate:
|
||||
template = repository.get_mail_template(key)
|
||||
try:
|
||||
# The templates are plain files in the store and may have been edited outside
|
||||
# CCMA, so the structural check runs again here: refusing to send beats
|
||||
# sending a mail with "{{#claims}}" in its text.
|
||||
validate_block_markers(key, template.subject, template.body)
|
||||
return render_mail_template(template, values, repeats)
|
||||
except MailTemplateError as exc:
|
||||
raise RepositoryError(
|
||||
f"Die E-Mail-Vorlage „{template_spec(key).label}“ ist fehlerhaft: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def compose_mail(
|
||||
*,
|
||||
recipient: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für den Versand ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = subject.strip()
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
message.set_content(body.strip() + "\n", charset="utf-8")
|
||||
return message.as_bytes()
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default as email_default_policy
|
||||
|
||||
from ccma.storage.repository import RepositoryError
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def _smtp_client(settings: dict) -> smtplib.SMTP:
|
||||
host = str(settings.get("smtp_host", "")).strip()
|
||||
if not host:
|
||||
raise RepositoryError("Kein SMTP-Server konfiguriert.")
|
||||
port = int(settings.get("smtp_port", 587))
|
||||
security = str(settings.get("smtp_security", "starttls"))
|
||||
client = (
|
||||
smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30)
|
||||
if security == "ssl"
|
||||
else smtplib.SMTP(host, port, timeout=30)
|
||||
)
|
||||
if security == "starttls":
|
||||
client.starttls(context=_ssl_context())
|
||||
username = str(settings.get("smtp_username", "")).strip()
|
||||
if username:
|
||||
client.login(username, str(settings.get("smtp_password", "")))
|
||||
return client
|
||||
|
||||
|
||||
def _imap_client(settings: dict) -> imaplib.IMAP4:
|
||||
host = str(settings.get("imap_host", "")).strip()
|
||||
if not host:
|
||||
raise RepositoryError("Kein IMAP-Server konfiguriert.")
|
||||
port = int(settings.get("imap_port", 993))
|
||||
security = str(settings.get("imap_security", "ssl"))
|
||||
client = (
|
||||
imaplib.IMAP4_SSL(host, port, timeout=30)
|
||||
if security == "ssl"
|
||||
else imaplib.IMAP4(host, port, timeout=30)
|
||||
)
|
||||
if security == "starttls":
|
||||
client.starttls(_ssl_context())
|
||||
username = str(settings.get("imap_username", "")).strip()
|
||||
if username:
|
||||
client.login(username, str(settings.get("imap_password", "")))
|
||||
return client
|
||||
|
||||
|
||||
@contextmanager
|
||||
def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]:
|
||||
"""Opens one authenticated SMTP connection to reuse across several sends (a SEPA
|
||||
info-mail batch may cover dozens of members -- reconnecting/re-authenticating per
|
||||
message would be slow and can trigger provider rate limits)."""
|
||||
try:
|
||||
client = _smtp_client(settings)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(OSError, smtplib.SMTPException):
|
||||
client.quit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]:
|
||||
try:
|
||||
client = _imap_client(settings)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(OSError, imaplib.IMAP4.error):
|
||||
client.logout()
|
||||
|
||||
|
||||
def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
|
||||
message = BytesParser(policy=email_default_policy).parsebytes(message_bytes)
|
||||
try:
|
||||
client.send_message(message)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
|
||||
|
||||
|
||||
def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None:
|
||||
try:
|
||||
status, response = client.append(_encode_imap_utf7(folder), flags, None, message_bytes)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"E-Mail konnte nicht in „{folder}“ abgelegt werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
|
||||
raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}")
|
||||
|
||||
|
||||
def ensure_imap_folder(client: imaplib.IMAP4, folder: str) -> None:
|
||||
"""Creates the folder on the server if it doesn't exist yet -- lets someone type
|
||||
a not-yet-existing Entwürfe/Gesendet folder name and have it just appear, instead
|
||||
of every append failing until it's created by hand in a separate mail client."""
|
||||
encoded = _encode_imap_utf7(folder)
|
||||
try:
|
||||
status, _response = client.select(encoded, readonly=True)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht geprüft werden: {exc}") from exc
|
||||
if status == "OK":
|
||||
return
|
||||
try:
|
||||
status, response = client.create(encoded)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {detail}")
|
||||
|
||||
|
||||
_LIST_RESPONSE_RE = re.compile(
|
||||
rb'^\((?P<flags>[^)]*)\)\s+(?:"(?P<qdelim>[^"]*)"|(?P<delim>\S+))\s+(?P<name>.+)$'
|
||||
)
|
||||
|
||||
|
||||
def list_imap_folders(settings: dict) -> list[str]:
|
||||
"""Human-readable (decoded) folder names for the Options dialog's folder picker
|
||||
-- fetched live from the server, so it only works once credentials are entered."""
|
||||
with imap_session(settings) as client:
|
||||
try:
|
||||
status, response = client.list()
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"Ordnerliste konnte nicht abgerufen werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
raise RepositoryError("Ordnerliste konnte nicht abgerufen werden.")
|
||||
folders = []
|
||||
for raw in response or []:
|
||||
if not raw:
|
||||
continue
|
||||
name = _parse_list_response(raw)
|
||||
if name:
|
||||
folders.append(name)
|
||||
return folders
|
||||
|
||||
|
||||
def _parse_list_response(raw: bytes) -> str | None:
|
||||
match = _LIST_RESPONSE_RE.match(raw)
|
||||
if not match:
|
||||
return None
|
||||
name = match.group("name").decode("utf-8", "replace").strip()
|
||||
if name.startswith('"') and name.endswith('"') and len(name) >= 2:
|
||||
name = name[1:-1]
|
||||
return _decode_imap_utf7(name)
|
||||
|
||||
|
||||
def _decode_imap_utf7(value: str) -> str:
|
||||
# Modified UTF-7 (RFC 3501 5.1.3): "&" takes the role of "+", and "," takes the
|
||||
# role of "/" inside the base64 run; "&-" is a literal ampersand.
|
||||
if "&" not in value:
|
||||
return value
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
length = len(value)
|
||||
while index < length:
|
||||
char = value[index]
|
||||
if char != "&":
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
end = value.find("-", index + 1)
|
||||
if end == -1:
|
||||
end = length
|
||||
chunk = value[index + 1 : end]
|
||||
if chunk == "":
|
||||
result.append("&")
|
||||
else:
|
||||
base64_chunk = chunk.replace(",", "/")
|
||||
base64_chunk += "=" * (-len(base64_chunk) % 4)
|
||||
try:
|
||||
result.append(base64.b64decode(base64_chunk).decode("utf-16-be"))
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
result.append("&" + chunk + "-")
|
||||
index = end + 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _encode_imap_utf7(value: str) -> str:
|
||||
if "&" not in value and all(32 <= ord(char) <= 126 for char in value):
|
||||
return value
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
length = len(value)
|
||||
while index < length:
|
||||
char = value[index]
|
||||
if char == "&":
|
||||
result.append("&-")
|
||||
index += 1
|
||||
continue
|
||||
if 32 <= ord(char) <= 126:
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
while index < length and not (32 <= ord(value[index]) <= 126):
|
||||
index += 1
|
||||
chunk = value[start:index]
|
||||
encoded = base64.b64encode(chunk.encode("utf-16-be")).decode("ascii").rstrip("=")
|
||||
result.append("&" + encoded.replace("/", ",") + "-")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def test_smtp_connection(settings: dict) -> None:
|
||||
with smtp_session(settings):
|
||||
pass
|
||||
|
||||
|
||||
def test_imap_connection(settings: dict) -> None:
|
||||
with imap_session(settings):
|
||||
pass
|
||||
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import (
|
||||
MEMBERSHIP_STATUS_LABELS,
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
Member,
|
||||
)
|
||||
from ccma.services.mail_composition import compose_mail, render_template, template_values
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
# Everyone the club still has a live relationship with. An ended membership is not
|
||||
# asked to check data the club is about to delete anyway, and an application has not
|
||||
# been decided on yet -- both can still be picked by hand in the dialog.
|
||||
DEFAULT_RECIPIENT_STATUSES: tuple[str, ...] = (
|
||||
"accepted_pending_payment",
|
||||
"active",
|
||||
"suspended_contribution",
|
||||
"resigned_end_of_year",
|
||||
"honorary",
|
||||
)
|
||||
|
||||
# A field the member is meant to complete reads better as a visible gap than as an
|
||||
# empty line the eye skips over.
|
||||
NOT_STORED = "(nicht hinterlegt)"
|
||||
|
||||
# Only makes sense next to a masked IBAN, so it is filled per member rather than
|
||||
# written into the template as a fixed line. It sits directly under the record and
|
||||
# reads as a footnote to it, which is also why it carries its own parentheses: a
|
||||
# dropped line must not leave a blank one behind.
|
||||
IBAN_HINT = (
|
||||
"(Die IBAN zeigen wir absichtlich nur verkürzt an – die letzten vier Stellen "
|
||||
"genügen zum Abgleich.)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedDataReviewMail:
|
||||
member_id: str
|
||||
recipient: str
|
||||
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
||||
return cleaned or "Mitglied"
|
||||
|
||||
|
||||
def _available_path(directory: Path, filename: str) -> Path:
|
||||
candidate = directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
index = 2
|
||||
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||
index += 1
|
||||
return directory / f"{stem}-{index}{suffix}"
|
||||
|
||||
|
||||
def mask_iban(iban: str) -> str:
|
||||
"""Shows enough of the IBAN to recognise the account (country code and the last
|
||||
four digits) without putting the full account number into an e-mail: the member
|
||||
only has to confirm it is still the right account."""
|
||||
cleaned = iban.replace(" ", "").strip()
|
||||
if len(cleaned) < 8:
|
||||
return cleaned
|
||||
return f"{cleaned[:2]}{'*' * (len(cleaned) - 6)}{cleaned[-4:]}"
|
||||
|
||||
|
||||
def member_data_fields(member: Member) -> list[tuple[str, str]]:
|
||||
"""The stored record as the member gets to see it: label and value, in the order
|
||||
they read naturally. Bank details are only listed for members who have any --
|
||||
asking someone who pays by transfer to check a mandate they never gave is noise."""
|
||||
fields: list[tuple[str, str]] = [
|
||||
("Mitgliedsnummer", member.member_number),
|
||||
("Name", " ".join(part for part in (member.first_name, member.last_name) if part)),
|
||||
("Nickname", member.nickname),
|
||||
("Geburtsdatum", format_date_for_display(member.birth_date)),
|
||||
("E-Mail", member.email),
|
||||
("Telefon", member.phone),
|
||||
("Straße und Hausnummer", member.street),
|
||||
]
|
||||
if member.address_addition.strip():
|
||||
fields.append(("Adresszusatz", member.address_addition))
|
||||
fields.extend(
|
||||
[
|
||||
(
|
||||
"PLZ und Ort",
|
||||
" ".join(part for part in (member.postal_code, member.city) if part),
|
||||
),
|
||||
("Land", member.country),
|
||||
("Mitgliedsstatus", MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)),
|
||||
("Mitglied seit", format_date_for_display(member.membership_started_at)),
|
||||
(
|
||||
"Zahlweise",
|
||||
PAYMENT_FREQUENCY_LABELS.get(
|
||||
member.payment_frequency, member.payment_frequency
|
||||
).capitalize(),
|
||||
),
|
||||
]
|
||||
)
|
||||
if has_bank_details(member):
|
||||
signed = format_date_for_display(member.mandate_signed_at)
|
||||
fields.extend(
|
||||
[
|
||||
("Kontoinhaber", member.account_holder),
|
||||
("IBAN", mask_iban(member.iban)),
|
||||
("Mandatsreferenz", member.mandate_reference),
|
||||
(
|
||||
"Lastschriftmandat",
|
||||
("aktiv" if member.mandate_active else "nicht aktiv")
|
||||
+ (f", erteilt am {signed}" if signed else ""),
|
||||
),
|
||||
]
|
||||
)
|
||||
return [(label, value.strip() or NOT_STORED) for label, value in fields]
|
||||
|
||||
|
||||
def has_bank_details(member: Member) -> bool:
|
||||
"""Whether the club stores anything about this member's account at all. Members
|
||||
who pay by transfer have nothing here, and asking them to check a mandate they
|
||||
never gave is noise."""
|
||||
return any(
|
||||
value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
|
||||
)
|
||||
|
||||
|
||||
def payment_reference(member: Member) -> str:
|
||||
"""What the member should write on a transfer. Both halves are offered because
|
||||
either one identifies the payment on its own: a member who has forgotten their
|
||||
number can fall back on the name, and a name shared by two members is told apart
|
||||
by the number."""
|
||||
return " – ".join(
|
||||
part
|
||||
for part in (
|
||||
member.member_number.strip(),
|
||||
" ".join(value for value in (member.first_name, member.last_name) if value).strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
|
||||
|
||||
def data_review_recipients(
|
||||
repository: MemberRepository, *, statuses: tuple[str, ...] | None = None
|
||||
) -> list[Member]:
|
||||
"""The members a data-review run is meant for by default -- an e-mail address is
|
||||
required, so members without one are left out here and reported by the dialog."""
|
||||
wanted = set(statuses if statuses is not None else DEFAULT_RECIPIENT_STATUSES)
|
||||
return [
|
||||
member
|
||||
for member in repository.list_members()
|
||||
if member.status in wanted and member.email.strip()
|
||||
]
|
||||
|
||||
|
||||
def data_review_mail_bytes(
|
||||
repository: MemberRepository,
|
||||
*,
|
||||
member: Member,
|
||||
organization: dict,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||
fields = member_data_fields(member)
|
||||
entries = [{"field.label": label, "field.value": value} for label, value in fields]
|
||||
values.update(
|
||||
{
|
||||
"member.iban_masked": mask_iban(member.iban),
|
||||
"data.sheet": "\n".join(f"{label}: {value}" for label, value in fields),
|
||||
"data.count": str(len(fields)),
|
||||
"data.iban_hint": IBAN_HINT if has_bank_details(member) else "",
|
||||
"payment.reference": payment_reference(member),
|
||||
}
|
||||
)
|
||||
rendered = render_template(repository, "data_review", values, {"data": entries})
|
||||
return compose_mail(
|
||||
recipient=member.email,
|
||||
subject=rendered.subject,
|
||||
body=rendered.body,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def generate_data_review_mails(
|
||||
repository: MemberRepository,
|
||||
member_ids: list[str],
|
||||
*,
|
||||
delivery_mode: str,
|
||||
output_directory: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
today: date | None = None,
|
||||
) -> tuple[list[GeneratedDataReviewMail], list[str]]:
|
||||
"""Sends one data-review mail per member. A member whose mail fails is reported as
|
||||
a warning and the run continues: a single bad address must not stop a mailing to
|
||||
the whole club halfway through."""
|
||||
repository.assert_writable()
|
||||
output: Path | None = None
|
||||
email_settings: dict | None = None
|
||||
smtp_ctx = nullcontext(None)
|
||||
imap_ctx = nullcontext(None)
|
||||
if delivery_mode == "local":
|
||||
if not output_directory:
|
||||
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
|
||||
output = Path(output_directory)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
elif delivery_mode == "send":
|
||||
email_settings = repository.get_email_settings()
|
||||
smtp_ctx = smtp_session(email_settings)
|
||||
if email_settings["imap_sent_enabled"]:
|
||||
imap_ctx = imap_session(email_settings)
|
||||
elif delivery_mode == "drafts":
|
||||
email_settings = repository.get_email_settings()
|
||||
imap_ctx = imap_session(email_settings)
|
||||
else:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
stamp = (today or date.today()).isoformat()
|
||||
generated: list[GeneratedDataReviewMail] = []
|
||||
warnings: list[str] = []
|
||||
# One connection for the whole run -- this mailing covers every member, so
|
||||
# reconnecting per recipient would be slow and invites provider rate limits.
|
||||
with smtp_ctx as smtp_client, imap_ctx as imap_client:
|
||||
if delivery_mode == "drafts":
|
||||
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
|
||||
elif delivery_mode == "send" and imap_client is not None:
|
||||
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
|
||||
for member_id in member_ids:
|
||||
try:
|
||||
member = repository.get_member(member_id)
|
||||
except RepositoryError as exc:
|
||||
warnings.append(f"{member_id}: {exc}")
|
||||
continue
|
||||
label = member.member_number or member.display_name
|
||||
if not member.email.strip():
|
||||
warnings.append(f"{label}: E-Mail-Adresse fehlt.")
|
||||
continue
|
||||
export_path: Path | None = None
|
||||
archive_path: Path | None = None
|
||||
# Once this mail has left the building, a later archiving failure must not
|
||||
# hide that it went out, nor abort the members that come after it.
|
||||
delivered = False
|
||||
archive_failure: Exception | None = None
|
||||
try:
|
||||
content = data_review_mail_bytes(
|
||||
repository,
|
||||
member=member,
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
filename = f"Datenpruefung-{stamp}-{_safe_filename(label)}.eml"
|
||||
archive_dir = (
|
||||
repository.members_root
|
||||
/ member.member_id
|
||||
/ "files"
|
||||
/ "documents"
|
||||
/ "Datenpruefung"
|
||||
)
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
if delivery_mode == "local":
|
||||
export_path = _available_path(output, filename)
|
||||
export_path.write_bytes(content)
|
||||
delivered = True
|
||||
elif delivery_mode == "send":
|
||||
send_via_smtp(smtp_client, content)
|
||||
delivered = True
|
||||
if imap_client is not None:
|
||||
append_message(
|
||||
imap_client,
|
||||
content,
|
||||
folder=email_settings["imap_sent_folder"],
|
||||
flags=r"(\Seen)",
|
||||
)
|
||||
else:
|
||||
append_message(
|
||||
imap_client,
|
||||
content,
|
||||
folder=email_settings["imap_drafts_folder"],
|
||||
flags=r"(\Draft)",
|
||||
)
|
||||
delivered = True
|
||||
archive_path.write_bytes(content)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
if not delivered:
|
||||
if export_path is not None:
|
||||
export_path.unlink(missing_ok=True)
|
||||
warnings.append(f"{label}: {exc}")
|
||||
continue
|
||||
warnings.append(
|
||||
f"{label}: E-Mail wurde versandt/abgelegt, konnte aber nicht archiviert "
|
||||
f"werden ({exc}); bitte manuell prüfen."
|
||||
)
|
||||
archive_failure = exc
|
||||
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
references: dict[str, str] = {}
|
||||
if archive_failure is None:
|
||||
references["document"] = archive_path.relative_to(
|
||||
repository.members_root / member.member_id / "files"
|
||||
).as_posix()
|
||||
data = {
|
||||
"sha256": digest,
|
||||
"recipient": member.email,
|
||||
"delivery_mode": delivery_mode,
|
||||
"field_count": len(member_data_fields(member)),
|
||||
}
|
||||
if archive_failure is not None:
|
||||
data["archive_error"] = str(archive_failure)
|
||||
repository.append_event(
|
||||
member.member_id,
|
||||
event_type="data_review_email_sent",
|
||||
summary="Bitte um Datenüberprüfung verschickt",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references=references,
|
||||
data=data,
|
||||
)
|
||||
generated.append(
|
||||
GeneratedDataReviewMail(
|
||||
member.member_id, member.email, export_path, archive_path
|
||||
)
|
||||
)
|
||||
return generated, warnings
|
||||
@@ -3,21 +3,35 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.contributions import claim_balance, claim_total, money_text, reminder_items_total
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import Member
|
||||
from ccma.services.mail_composition import (
|
||||
compose_mail,
|
||||
payment_instructions,
|
||||
render_template,
|
||||
template_values,
|
||||
)
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedReminderMail:
|
||||
export_path: Path
|
||||
# None for "send"/"drafts" delivery -- there is no local file, the mail was
|
||||
# handed straight to the mail server. Always set for "local" delivery.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
sha256: str
|
||||
|
||||
@@ -39,9 +53,9 @@ def _available_path(directory: Path, filename: str) -> Path:
|
||||
|
||||
|
||||
def reminder_mail_bytes(
|
||||
repository: MemberRepository,
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
member: Member,
|
||||
reminder: dict,
|
||||
claim: dict,
|
||||
balance: str,
|
||||
@@ -51,53 +65,72 @@ def reminder_mail_bytes(
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = f"{reminder.get('name', 'Zahlungserinnerung')} – {claim.get('title', '')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
iban = str(organization.get("iban", "")).strip()
|
||||
bic = str(organization.get("bic", "")).strip()
|
||||
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
|
||||
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||
detail = str(reminder.get("detail", "")).strip()
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {first_name.strip() or 'Mitglied'},",
|
||||
"",
|
||||
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ "
|
||||
"ist noch ein Betrag offen.",
|
||||
"",
|
||||
f"Offener Betrag: {balance} Euro",
|
||||
f"Ursprünglich fällig: "
|
||||
f"{format_date_for_display(str(claim.get('due_date', '')))}",
|
||||
f"Neue Zahlungsfrist: "
|
||||
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
|
||||
f"Mahngebühr: {reminder.get('fee', '0.00')} Euro",
|
||||
f"Hinweis: {detail}" if detail else "",
|
||||
"",
|
||||
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
|
||||
payment_details,
|
||||
"",
|
||||
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
|
||||
"betrachte sie bitte als gegenstandslos.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
fee_items = reminder.get("items") or []
|
||||
due_date = format_date_for_display(str(claim.get("due_date", "")))
|
||||
payment_deadline = format_date_for_display(str(reminder.get("payment_deadline", "")))
|
||||
reference = " – ".join(
|
||||
part
|
||||
for part in (
|
||||
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
|
||||
str(claim.get("title", "")).strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
values.update(
|
||||
{
|
||||
"claim.title": str(claim.get("title", "")),
|
||||
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
|
||||
"claim.due_date": due_date,
|
||||
"claim.amount": money_text(claim_total(claim)),
|
||||
"claim.balance": balance,
|
||||
"reminder.name": str(reminder.get("name", "Zahlungserinnerung")),
|
||||
"reminder.level": str(reminder.get("level", "")),
|
||||
"reminder.payment_deadline": payment_deadline,
|
||||
"reminder.detail": detail,
|
||||
"reminder.hint": f"Hinweis: {detail}" if detail else "",
|
||||
"reminder.items": "\n".join(
|
||||
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
||||
for item in fee_items
|
||||
),
|
||||
"reminder.fee_total": money_text(reminder_items_total(reminder)),
|
||||
"payment.reference": reference,
|
||||
# A dunning mail only goes out because the money did not arrive, so it
|
||||
# asks for a transfer even from a member with an active mandate: this
|
||||
# claim has left the direct-debit run and telling them "we collect it,
|
||||
# you need not do anything" would contradict the letter it carries.
|
||||
"payment.instructions": payment_instructions(
|
||||
member,
|
||||
organization,
|
||||
due_date=payment_deadline,
|
||||
reference=reference,
|
||||
expect_transfer=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
rendered = render_template(
|
||||
repository,
|
||||
"reminder",
|
||||
values,
|
||||
{
|
||||
"reminder.items": [
|
||||
{
|
||||
"item.description": str(item.get("description", "")),
|
||||
"item.amount": str(item.get("amount", "0.00")),
|
||||
}
|
||||
for item in fee_items
|
||||
]
|
||||
},
|
||||
)
|
||||
return compose_mail(
|
||||
recipient=member.email,
|
||||
subject=rendered.subject,
|
||||
body=rendered.body,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
created_at=created_at,
|
||||
)
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
def generate_and_send_reminder_mail(
|
||||
@@ -106,11 +139,15 @@ def generate_and_send_reminder_mail(
|
||||
claim_id: str,
|
||||
reminder_id: str,
|
||||
*,
|
||||
output_path: Path | str,
|
||||
delivery_mode: str,
|
||||
output_path: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> GeneratedReminderMail:
|
||||
if delivery_mode not in {"local", "send", "drafts"}:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
repository.assert_writable()
|
||||
member = repository.get_member(member_id)
|
||||
data, claim = repository.get_claim(member_id, claim_id)
|
||||
reminder = repository._find_reminder(data, claim_id, reminder_id)
|
||||
@@ -120,61 +157,114 @@ def generate_and_send_reminder_mail(
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||
target = Path(output_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||
target: Path | None = None
|
||||
if delivery_mode == "local":
|
||||
if not output_path:
|
||||
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
|
||||
target = Path(output_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||
|
||||
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
|
||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = reminder_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = (
|
||||
f"{datetime.now().date().isoformat()}-"
|
||||
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
|
||||
)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||
export_path: Path | None = None
|
||||
# Once the mail has actually left the building -- landed on the SMTP server, been
|
||||
# filed into an IMAP folder, or been written to the local export file -- it must
|
||||
# not be un-sent again: a later failure (Sent-folder copy, moving the archive file
|
||||
# into place) can no longer roll the reminder back to "draft", or a retry could
|
||||
# send/file the same Mahnung a second time and double-book its fee. Everything
|
||||
# before that point -- including preparing the archive directory/path -- still
|
||||
# rolls back on failure, since nothing has actually gone out yet.
|
||||
delivered = False
|
||||
archive_failure: Exception | None = None
|
||||
archive_temp: Path | None = None
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = reminder_mail_bytes(
|
||||
repository,
|
||||
member=member,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = (
|
||||
f"{datetime.now().date().isoformat()}-"
|
||||
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
|
||||
)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||
archive_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
if delivery_mode == "local":
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
finally:
|
||||
export_temp.unlink(missing_ok=True)
|
||||
export_path = target
|
||||
delivered = True
|
||||
elif delivery_mode == "send":
|
||||
settings = repository.get_email_settings()
|
||||
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
||||
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||
send_via_smtp(smtp_client, content)
|
||||
delivered = True
|
||||
if imap_client is not None:
|
||||
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||
append_message(
|
||||
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
settings = repository.get_email_settings()
|
||||
with imap_session(settings) as client:
|
||||
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
|
||||
delivered = True
|
||||
os.replace(archive_temp, archive_path)
|
||||
finally:
|
||||
export_temp.unlink(missing_ok=True)
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
except Exception as exc:
|
||||
if archive_temp is not None:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
if not delivered:
|
||||
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
|
||||
raise
|
||||
archive_failure = exc
|
||||
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
references = {"claim_id": claim_id, "reminder_id": reminder_id}
|
||||
if archive_failure is None:
|
||||
references["document"] = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
data = {
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
|
||||
"delivery_mode": delivery_mode,
|
||||
}
|
||||
if archive_failure is not None:
|
||||
data["archive_error"] = str(archive_failure)
|
||||
# Log the event even when archiving failed -- the mail was already delivered/filed,
|
||||
# so this must stay a traceable "sent" record instead of silently vanishing.
|
||||
repository.append_event(
|
||||
member_id,
|
||||
event_type="reminder_email_sent",
|
||||
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={
|
||||
"claim_id": claim_id,
|
||||
"reminder_id": reminder_id,
|
||||
"document": relative_path,
|
||||
},
|
||||
data={
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
|
||||
},
|
||||
references=references,
|
||||
data=data,
|
||||
)
|
||||
return GeneratedReminderMail(target, archive_path, digest)
|
||||
if archive_failure is not None:
|
||||
raise RepositoryError(
|
||||
"Die Mahnung wurde zugestellt bzw. abgelegt, konnte aber nicht vollständig "
|
||||
f"archiviert werden: {archive_failure}. Die Mahnung bleibt als versandt gebucht "
|
||||
"-- bitte die Mitgliedsakte manuell prüfen."
|
||||
) from archive_failure
|
||||
return GeneratedReminderMail(export_path, archive_path, digest)
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.contributions import claim_balance, claim_was_dunned
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
||||
|
||||
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
||||
@@ -63,6 +63,7 @@ def pending_direct_debits(
|
||||
for member in repository.list_members():
|
||||
if not member.mandate_active:
|
||||
continue
|
||||
member_label = member.member_number or member.display_name
|
||||
missing = [
|
||||
label
|
||||
for value, label in (
|
||||
@@ -73,13 +74,13 @@ def pending_direct_debits(
|
||||
if not str(value).strip()
|
||||
]
|
||||
if missing:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
|
||||
warnings.append(f"{member_label}: {', '.join(missing)} fehlt.")
|
||||
continue
|
||||
try:
|
||||
validate_iban(member.iban)
|
||||
date.fromisoformat(member.mandate_signed_at)
|
||||
except (RepositoryError, ValueError) as exc:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||||
warnings.append(f"{member_label}: {exc}")
|
||||
continue
|
||||
data = repository.get_contributions(member.member_id)
|
||||
selected = []
|
||||
@@ -92,9 +93,22 @@ def pending_direct_debits(
|
||||
except ValueError:
|
||||
continue
|
||||
balance = claim_balance(data, claim)
|
||||
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
|
||||
selected.append(claim)
|
||||
amount += balance
|
||||
if not ((due_from is None or claim_due >= due_from) and claim_due <= due_until):
|
||||
continue
|
||||
if balance <= 0:
|
||||
continue
|
||||
if claim_was_dunned(data, claim):
|
||||
# The debit for this claim already bounced and the member was dunned:
|
||||
# the letter told them to transfer the money themselves, so collecting
|
||||
# it behind their back would contradict it (and can bounce again, with
|
||||
# another fee). Reverting the sent reminder puts it back in the run.
|
||||
warnings.append(
|
||||
f"{member_label}: „{claim.get('title') or 'Forderung'}“ wurde bereits "
|
||||
"gemahnt – Zahlung per Überweisung erwartet, kein erneuter Einzug."
|
||||
)
|
||||
continue
|
||||
selected.append(claim)
|
||||
amount += balance
|
||||
if not selected:
|
||||
continue
|
||||
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
|
||||
|
||||
+148
-92
@@ -2,13 +2,20 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.domain.models import Member
|
||||
from ccma.services.mail_composition import compose_mail, render_template, template_values
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.services.sepa import DirectDebit
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
@@ -17,7 +24,8 @@ from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
class GeneratedDebitMail:
|
||||
member_id: str
|
||||
recipient: str
|
||||
export_path: Path
|
||||
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
|
||||
|
||||
@@ -38,55 +46,35 @@ def _available_path(directory: Path, filename: str) -> Path:
|
||||
|
||||
|
||||
def debit_mail_bytes(
|
||||
repository: MemberRepository,
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
member: Member,
|
||||
debit: DirectDebit,
|
||||
collection_date: date,
|
||||
creditor_id: str,
|
||||
organization: dict,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Info-Mails ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = f"SEPA-Lastschrift C3MA – {collection_date.strftime('%d.%m.%Y')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||
values.update(
|
||||
{
|
||||
"debit.amount": f"{debit.amount:.2f}",
|
||||
"debit.collection_date": collection_date.strftime("%d.%m.%Y"),
|
||||
"debit.purpose": debit.purpose,
|
||||
"debit.mandate_reference": debit.mandate_reference,
|
||||
}
|
||||
)
|
||||
greeting = first_name.strip() or "Mitglied"
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {greeting},",
|
||||
"",
|
||||
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
|
||||
"SEPA-Lastschrift einziehen werden:",
|
||||
"",
|
||||
f"Betrag: {debit.amount:.2f} Euro",
|
||||
f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}",
|
||||
f"Verwendungszweck: {debit.purpose}",
|
||||
f"Mandatsreferenz: {debit.mandate_reference}",
|
||||
f"Gläubiger-Identifikationsnummer: {creditor_id}",
|
||||
"",
|
||||
"Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
rendered = render_template(repository, "sepa", values)
|
||||
return compose_mail(
|
||||
recipient=member.email,
|
||||
subject=rendered.subject,
|
||||
body=rendered.body,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
created_at=created_at,
|
||||
)
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
def generate_debit_mails(
|
||||
@@ -94,67 +82,135 @@ def generate_debit_mails(
|
||||
debits: list[DirectDebit],
|
||||
*,
|
||||
collection_date: date,
|
||||
output_directory: Path | str,
|
||||
delivery_mode: str,
|
||||
output_directory: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
||||
output = Path(output_directory)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
repository.assert_writable()
|
||||
output: Path | None = None
|
||||
email_settings: dict | None = None
|
||||
smtp_ctx = nullcontext(None)
|
||||
imap_ctx = nullcontext(None)
|
||||
if delivery_mode == "local":
|
||||
if not output_directory:
|
||||
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
|
||||
output = Path(output_directory)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
elif delivery_mode == "send":
|
||||
email_settings = repository.get_email_settings()
|
||||
smtp_ctx = smtp_session(email_settings)
|
||||
if email_settings["imap_sent_enabled"]:
|
||||
imap_ctx = imap_session(email_settings)
|
||||
elif delivery_mode == "drafts":
|
||||
email_settings = repository.get_email_settings()
|
||||
imap_ctx = imap_session(email_settings)
|
||||
else:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
creditor_id = str(organization.get("creditor_id", "")).strip()
|
||||
if not creditor_id:
|
||||
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
|
||||
generated: list[GeneratedDebitMail] = []
|
||||
warnings: list[str] = []
|
||||
for debit in debits:
|
||||
member = repository.get_member(debit.member_id)
|
||||
if not member.email.strip():
|
||||
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
||||
continue
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
debit=debit,
|
||||
collection_date=collection_date,
|
||||
creditor_id=creditor_id,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
filename = (
|
||||
f"SEPA-Info-{collection_date.isoformat()}-"
|
||||
f"{_safe_filename(member.member_number or member.display_name)}.eml"
|
||||
)
|
||||
export_path = _available_path(output, filename)
|
||||
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
export_path.write_bytes(content)
|
||||
try:
|
||||
archive_path.write_bytes(content)
|
||||
except OSError:
|
||||
export_path.unlink(missing_ok=True)
|
||||
raise
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member.member_id / "files"
|
||||
).as_posix()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
repository.append_event(
|
||||
member.member_id,
|
||||
event_type="sepa_notification_generated",
|
||||
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"document": relative_path},
|
||||
data={
|
||||
# One connection (or two, for "send" with a Sent-folder copy) is opened for the
|
||||
# whole batch -- a run can cover dozens of members -- instead of reconnecting/
|
||||
# re-authenticating per recipient.
|
||||
with smtp_ctx as smtp_client, imap_ctx as imap_client:
|
||||
if delivery_mode == "drafts":
|
||||
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
|
||||
elif delivery_mode == "send" and imap_client is not None:
|
||||
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
|
||||
for debit in debits:
|
||||
member = repository.get_member(debit.member_id)
|
||||
if not member.email.strip():
|
||||
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
||||
continue
|
||||
export_path: Path | None = None
|
||||
archive_path: Path | None = None
|
||||
# Once the mail has actually left the building for this debit -- SMTP
|
||||
# accepted it, it's filed in the IMAP folder, or the local export file was
|
||||
# written -- a later archiving failure must not abort the whole batch and
|
||||
# lose track of the fact that this one was already delivered; nor may it
|
||||
# abort earlier/later debits that have nothing to do with this failure.
|
||||
delivered = False
|
||||
archive_failure: Exception | None = None
|
||||
try:
|
||||
content = debit_mail_bytes(
|
||||
repository,
|
||||
member=member,
|
||||
debit=debit,
|
||||
collection_date=collection_date,
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
filename = (
|
||||
f"SEPA-Info-{collection_date.isoformat()}-"
|
||||
f"{_safe_filename(member.member_number or member.display_name)}.eml"
|
||||
)
|
||||
archive_dir = (
|
||||
repository.members_root / member.member_id / "files" / "documents" / "SEPA"
|
||||
)
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
if delivery_mode == "local":
|
||||
export_path = _available_path(output, filename)
|
||||
export_path.write_bytes(content)
|
||||
delivered = True
|
||||
elif delivery_mode == "send":
|
||||
send_via_smtp(smtp_client, content)
|
||||
delivered = True
|
||||
if imap_client is not None:
|
||||
append_message(
|
||||
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
append_message(
|
||||
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||
)
|
||||
delivered = True
|
||||
archive_path.write_bytes(content)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
if not delivered:
|
||||
if export_path is not None:
|
||||
export_path.unlink(missing_ok=True)
|
||||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||||
continue
|
||||
warnings.append(
|
||||
f"{member.member_number or member.display_name}: E-Mail wurde versandt/abgelegt, "
|
||||
f"konnte aber nicht archiviert werden ({exc}); bitte manuell prüfen."
|
||||
)
|
||||
archive_failure = exc
|
||||
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
references = {}
|
||||
if archive_failure is None:
|
||||
references["document"] = archive_path.relative_to(
|
||||
repository.members_root / member.member_id / "files"
|
||||
).as_posix()
|
||||
data = {
|
||||
"sha256": digest,
|
||||
"recipient": member.email,
|
||||
"collection_date": collection_date.isoformat(),
|
||||
"amount": f"{debit.amount:.2f}",
|
||||
},
|
||||
)
|
||||
generated.append(
|
||||
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
||||
)
|
||||
"delivery_mode": delivery_mode,
|
||||
}
|
||||
if archive_failure is not None:
|
||||
data["archive_error"] = str(archive_failure)
|
||||
repository.append_event(
|
||||
member.member_id,
|
||||
event_type="sepa_notification_generated",
|
||||
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references=references,
|
||||
data=data,
|
||||
)
|
||||
generated.append(
|
||||
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
||||
)
|
||||
return generated, warnings
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.domain.contributions import claim_balance, claim_status, claim_total, money_text
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import Member
|
||||
from ccma.services.mail_composition import (
|
||||
compose_mail,
|
||||
payment_instructions,
|
||||
render_template,
|
||||
template_values,
|
||||
)
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
OPEN_CLAIM_STATUSES = {"open", "partially_paid", "overdue"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedWelcomeMail:
|
||||
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
sha256: str
|
||||
|
||||
|
||||
def open_claims(repository: MemberRepository, member_id: str) -> list[dict]:
|
||||
"""The claims a welcome mail can bill: everything still owed, which for a fresh
|
||||
member is the admission fee plus the first contribution claim(s)."""
|
||||
data = repository.get_contributions(member_id)
|
||||
selected = [
|
||||
claim for claim in data.claims if claim_status(data, claim) in OPEN_CLAIM_STATUSES
|
||||
]
|
||||
return sorted(selected, key=lambda claim: (str(claim.get("due_date", "")), str(claim.get("title", ""))))
|
||||
|
||||
|
||||
def membership_start(member: Member, *, today: date | None = None) -> date:
|
||||
"""The date the membership (and with it the first contribution) starts. A member
|
||||
accepted this month usually starts on the 1st of the next one, and that date
|
||||
cannot be stored yet -- "Mitglied seit" rejects future dates."""
|
||||
try:
|
||||
return date.fromisoformat(str(member.membership_started_at))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
reference = date.fromisoformat(str(member.accepted_at))
|
||||
except ValueError:
|
||||
reference = today or date.today()
|
||||
return date(reference.year + reference.month // 12, reference.month % 12 + 1, 1)
|
||||
|
||||
|
||||
def welcome_mail_bytes(
|
||||
repository: MemberRepository,
|
||||
*,
|
||||
member: Member,
|
||||
claims: list[dict],
|
||||
balances: dict[str, Decimal],
|
||||
organization: dict,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||
entries = [
|
||||
{
|
||||
"claim.title": str(claim.get("title", "")),
|
||||
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
|
||||
"claim.due_date": format_date_for_display(str(claim.get("due_date", ""))),
|
||||
"claim.amount": money_text(claim_total(claim)),
|
||||
"claim.balance": money_text(balances[str(claim.get("claim_id", ""))]),
|
||||
}
|
||||
for claim in claims
|
||||
]
|
||||
total = sum((balances[str(claim.get("claim_id", ""))] for claim in claims), Decimal("0"))
|
||||
due_dates = sorted(
|
||||
str(claim.get("due_date", "")) for claim in claims if str(claim.get("due_date", ""))
|
||||
)
|
||||
first_due_date = format_date_for_display(due_dates[0]) if due_dates else ""
|
||||
reference = " – ".join(
|
||||
part
|
||||
for part in (
|
||||
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
|
||||
"Mitgliedsbeitrag",
|
||||
)
|
||||
if part
|
||||
)
|
||||
values.update(
|
||||
{
|
||||
"membership.start_date": format_date_for_display(
|
||||
membership_start(member).isoformat()
|
||||
),
|
||||
"claims.list": "\n".join(
|
||||
f"{entry['claim.description']} (fällig {entry['claim.due_date']}): "
|
||||
f"{entry['claim.balance']} Euro"
|
||||
for entry in entries
|
||||
),
|
||||
"claims.total": money_text(total),
|
||||
"claims.count": str(len(entries)),
|
||||
"claims.first_due_date": first_due_date,
|
||||
"payment.reference": reference,
|
||||
"payment.instructions": payment_instructions(
|
||||
member, organization, due_date=first_due_date, reference=reference
|
||||
),
|
||||
}
|
||||
)
|
||||
rendered = render_template(repository, "welcome", values, {"claims": entries})
|
||||
return compose_mail(
|
||||
recipient=member.email,
|
||||
subject=rendered.subject,
|
||||
body=rendered.body,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def generate_and_send_welcome_mail(
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
*,
|
||||
claim_ids: list[str] | None = None,
|
||||
delivery_mode: str,
|
||||
output_path: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> GeneratedWelcomeMail:
|
||||
if delivery_mode not in {"local", "send", "drafts"}:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
# The mail is archived in the member file, so a read-only store rules the whole
|
||||
# path out -- said here, before anything is rendered or handed to a mail server.
|
||||
repository.assert_writable()
|
||||
member = repository.get_member(member_id)
|
||||
if not member.email.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
data = repository.get_contributions(member_id)
|
||||
selected_ids = None if claim_ids is None else {str(value) for value in claim_ids}
|
||||
claims = [
|
||||
claim
|
||||
for claim in open_claims(repository, member_id)
|
||||
if selected_ids is None or str(claim.get("claim_id", "")) in selected_ids
|
||||
]
|
||||
if selected_ids is not None and len(claims) != len(selected_ids):
|
||||
raise RepositoryError("Mindestens eine ausgewählte Forderung ist nicht mehr offen.")
|
||||
balances = {
|
||||
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
|
||||
}
|
||||
target: Path | None = None
|
||||
if delivery_mode == "local":
|
||||
if not output_path:
|
||||
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
|
||||
target = Path(output_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = welcome_mail_bytes(
|
||||
repository,
|
||||
member=member,
|
||||
claims=claims,
|
||||
balances=balances,
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
export_path: Path | None = None
|
||||
archive_path: Path | None = None
|
||||
archive_temp: Path | None = None
|
||||
# Same rule as the dunning/SEPA mails: once the mail has reached the SMTP server,
|
||||
# the IMAP folder or the local export file, a later archiving failure must not
|
||||
# hide that it went out -- the event is written either way and the archiving
|
||||
# problem is reported afterwards.
|
||||
delivered = False
|
||||
archive_failure: Exception | None = None
|
||||
try:
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
|
||||
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||
archive_temp.write_bytes(content)
|
||||
if delivery_mode == "local":
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
finally:
|
||||
export_temp.unlink(missing_ok=True)
|
||||
export_path = target
|
||||
delivered = True
|
||||
elif delivery_mode == "send":
|
||||
settings = repository.get_email_settings()
|
||||
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
||||
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||
send_via_smtp(smtp_client, content)
|
||||
delivered = True
|
||||
if imap_client is not None:
|
||||
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||
append_message(
|
||||
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
settings = repository.get_email_settings()
|
||||
with imap_session(settings) as client:
|
||||
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||
append_message(
|
||||
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||
)
|
||||
delivered = True
|
||||
os.replace(archive_temp, archive_path)
|
||||
except Exception as exc:
|
||||
if archive_temp is not None:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
if not delivered:
|
||||
raise
|
||||
archive_failure = exc
|
||||
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
references: dict[str, str] = {}
|
||||
if archive_failure is None:
|
||||
references["document"] = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
event_data = {
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
|
||||
"delivery_mode": delivery_mode,
|
||||
}
|
||||
if archive_failure is not None:
|
||||
event_data["archive_error"] = str(archive_failure)
|
||||
repository.append_event(
|
||||
member_id,
|
||||
event_type="welcome_email_sent",
|
||||
summary="Willkommens- und Erstrechnungsmail erzeugt",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references=references,
|
||||
data=event_data,
|
||||
)
|
||||
if archive_failure is not None:
|
||||
raise RepositoryError(
|
||||
"Die Willkommensmail wurde zugestellt bzw. abgelegt, konnte aber nicht "
|
||||
f"vollständig archiviert werden: {archive_failure}. Bitte die Mitgliedsakte "
|
||||
"manuell prüfen."
|
||||
) from archive_failure
|
||||
return GeneratedWelcomeMail(export_path, archive_path, digest)
|
||||
|
||||
|
||||
def _available_path(directory: Path, filename: str) -> Path:
|
||||
candidate = directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
index = 2
|
||||
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||
index += 1
|
||||
return directory / f"{stem}-{index}{suffix}"
|
||||
@@ -63,6 +63,20 @@ def write_json_atomic(path: Path, data: Any) -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
+495
-42
@@ -17,6 +17,8 @@ from ccma.domain.contributions import (
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_items,
|
||||
claim_settled_total,
|
||||
claim_total,
|
||||
contribution_override_ranges_overlap,
|
||||
credit_allocated_total,
|
||||
@@ -25,6 +27,7 @@ from ccma.domain.contributions import (
|
||||
materialize_claim_items,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import (
|
||||
DateValidationError,
|
||||
@@ -32,6 +35,16 @@ from ccma.domain.dates import (
|
||||
parse_month_input,
|
||||
validate_member_dates,
|
||||
)
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplate,
|
||||
MailTemplateError,
|
||||
default_mail_template,
|
||||
parse_mail_template,
|
||||
serialize_mail_template,
|
||||
template_spec,
|
||||
validate_mail_template,
|
||||
)
|
||||
from ccma.domain.models import (
|
||||
ASSET_CUSTODY_TYPE_LABELS,
|
||||
ASSET_OWNER_TYPE_LABELS,
|
||||
@@ -43,13 +56,24 @@ from ccma.domain.models import (
|
||||
Event,
|
||||
Member,
|
||||
)
|
||||
from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic
|
||||
from ccma.storage.atomic import (
|
||||
json_content_hash_matches,
|
||||
read_json,
|
||||
write_json_atomic,
|
||||
write_text_atomic,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ReadOnlyStoreError(RepositoryError):
|
||||
"""Raised instead of a bare PermissionError when the store is mounted read-only:
|
||||
the encrypted volume holding the member data can be mounted without write access,
|
||||
and CCMA stays usable for reading in that case."""
|
||||
|
||||
|
||||
DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
|
||||
|
||||
DEFAULT_CONTRIBUTION_RULES = [
|
||||
@@ -65,8 +89,6 @@ DEFAULT_CONTRIBUTION_RULES = [
|
||||
"entry_proration": {"mode": "monthly", "started_month": "included"},
|
||||
"first_payment_due_days_after_acceptance": 28,
|
||||
"issue_days_before_due": 30,
|
||||
"reminder_fee": "5.00",
|
||||
"failed_debit_fee": "5.00",
|
||||
},
|
||||
{
|
||||
"rule_id": "standard-2022",
|
||||
@@ -80,8 +102,6 @@ DEFAULT_CONTRIBUTION_RULES = [
|
||||
"entry_proration": {"mode": "monthly", "started_month": "included"},
|
||||
"first_payment_due_days_after_acceptance": 28,
|
||||
"issue_days_before_due": 30,
|
||||
"reminder_fee": "5.00",
|
||||
"failed_debit_fee": "5.00",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -129,8 +149,37 @@ DEFAULT_CONFIGURATION = {
|
||||
"payment_deadline_days": 14,
|
||||
},
|
||||
],
|
||||
# Reusable description/amount presets offered as rows when recording a
|
||||
# reminder or a failed direct debit; boards can add/edit/remove freely.
|
||||
"standard_fee_items": [
|
||||
{"description": "Mahngebühr", "default_amount": "5.00"},
|
||||
{"description": "Rücklastschriftgebühr", "default_amount": "5.00"},
|
||||
{"description": "Bankgebühr", "default_amount": "0.00"},
|
||||
{"description": "Porto", "default_amount": "0.00"},
|
||||
],
|
||||
},
|
||||
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
|
||||
# "local" reproduces the historical behaviour (always ask for a save location,
|
||||
# never touch the network) so repositories that never configured this stay on
|
||||
# exactly what they had before -- it is a first-class choice, not just a fallback.
|
||||
"email_settings": {
|
||||
"delivery_mode": "local",
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_username": "",
|
||||
"smtp_password": "",
|
||||
"imap_host": "",
|
||||
"imap_port": 993,
|
||||
"imap_security": "ssl",
|
||||
"imap_username": "",
|
||||
"imap_password": "",
|
||||
"imap_drafts_folder": "INBOX.Entwürfe",
|
||||
# Sent-copy applies only to "send" (a direct SMTP send has no server-side
|
||||
# "Sent" copy on its own, unlike drafts which are inherently server-side).
|
||||
"imap_sent_enabled": False,
|
||||
"imap_sent_folder": "INBOX.Sent",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -139,8 +188,60 @@ class MemberRepository:
|
||||
self.root = Path(root).expanduser().resolve()
|
||||
self.members_root = self.root / "members"
|
||||
self.assets_root = self.root / "assets"
|
||||
self._read_only: bool | None = None
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
"""Whether the store can be written to at all. Probed once and cached: the
|
||||
answer only changes when the volume is remounted, which needs a restart (or
|
||||
an explicit refresh_read_only()) anyway."""
|
||||
if self._read_only is None:
|
||||
self._read_only = self.detect_read_only()
|
||||
return self._read_only
|
||||
|
||||
def refresh_read_only(self) -> bool:
|
||||
self._read_only = self.detect_read_only()
|
||||
return self._read_only
|
||||
|
||||
def detect_read_only(self) -> bool:
|
||||
# Probe by actually writing: permissions, mount options and filesystem state
|
||||
# all matter, and only an attempt covers them together. A store directory that
|
||||
# does not exist yet counts as writable when its nearest existing parent is --
|
||||
# that is an ordinary first start, not a read-only volume.
|
||||
target = self.root
|
||||
while not target.exists() and target != target.parent:
|
||||
target = target.parent
|
||||
probe = target / f".ccma-write-probe-{uuid4().hex}"
|
||||
try:
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
return True
|
||||
return False
|
||||
|
||||
def assert_writable(self) -> None:
|
||||
if self.read_only:
|
||||
raise ReadOnlyStoreError(
|
||||
"Der Mitglieder-Store ist schreibgeschützt eingebunden. Änderungen sind "
|
||||
"erst wieder möglich, wenn der Store mit Schreibrechten eingebunden ist."
|
||||
)
|
||||
|
||||
def _write_json(self, path: Path, data) -> None:
|
||||
self.assert_writable()
|
||||
write_json_atomic(path, data)
|
||||
|
||||
def initialize(self) -> None:
|
||||
# A read-only store cannot be set up -- but it also does not need to be, as
|
||||
# long as it was initialized while it was still writable. Opening one that was
|
||||
# never initialized is the only case that has to fail here.
|
||||
if self.read_only:
|
||||
if not (self.root / "repository.json").is_file():
|
||||
raise ReadOnlyStoreError(
|
||||
f"Der Mitglieder-Store {self.root} ist schreibgeschützt eingebunden und "
|
||||
"enthält keine repository.json. Ein neuer Store kann nur mit "
|
||||
"Schreibrechten angelegt werden."
|
||||
)
|
||||
return
|
||||
self.members_root.mkdir(parents=True, exist_ok=True)
|
||||
self.assets_root.mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "rules").mkdir(parents=True, exist_ok=True)
|
||||
@@ -155,9 +256,21 @@ class MemberRepository:
|
||||
destination = templates_root / destination_name
|
||||
if source.is_file() and not destination.exists():
|
||||
shutil.copyfile(source, destination)
|
||||
mail_templates_root = self.mail_templates_root
|
||||
mail_templates_root.mkdir(parents=True, exist_ok=True)
|
||||
for spec in MAIL_TEMPLATES:
|
||||
destination = mail_templates_root / spec.filename
|
||||
if not destination.exists():
|
||||
try:
|
||||
template = default_mail_template(spec.key)
|
||||
write_text_atomic(
|
||||
destination, serialize_mail_template(template.subject, template.body)
|
||||
)
|
||||
except (MailTemplateError, OSError):
|
||||
continue
|
||||
config_path = self.root / "repository.json"
|
||||
if not config_path.exists():
|
||||
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
||||
self._write_json(config_path, DEFAULT_CONFIGURATION)
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
@@ -348,6 +461,7 @@ class MemberRepository:
|
||||
selected_number = self._allocate_member_number(
|
||||
policy["pattern"], policy["allocation_strategy"]
|
||||
)
|
||||
self.assert_writable()
|
||||
member_id = str(uuid4())
|
||||
directory = self._member_path(member_id)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
@@ -363,8 +477,8 @@ class MemberRepository:
|
||||
birth_date=birth_date,
|
||||
application_date=application_date,
|
||||
)
|
||||
write_json_atomic(directory / "member.json", member.to_dict())
|
||||
write_json_atomic(directory / "contributions.json", ContributionData().to_dict())
|
||||
self._write_json(directory / "member.json", member.to_dict())
|
||||
self._write_json(directory / "contributions.json", ContributionData().to_dict())
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="member_created",
|
||||
@@ -412,7 +526,7 @@ class MemberRepository:
|
||||
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
|
||||
changes = self._summarize_changes(existing, member)
|
||||
member.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
write_json_atomic(self._member_path(member.member_id) / "member.json", member.to_dict())
|
||||
self._write_json(self._member_path(member.member_id) / "member.json", member.to_dict())
|
||||
if changes:
|
||||
self.append_event(
|
||||
member.member_id,
|
||||
@@ -473,6 +587,7 @@ class MemberRepository:
|
||||
if deposit_amount < 0:
|
||||
raise RepositoryError("Die Kaution darf nicht negativ sein.")
|
||||
self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "")
|
||||
self.assert_writable()
|
||||
asset_id = str(uuid4())
|
||||
directory = self._asset_path(asset_id)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
@@ -494,7 +609,7 @@ class MemberRepository:
|
||||
condition=condition.strip(),
|
||||
estimated_value=estimated_value.strip(),
|
||||
)
|
||||
write_json_atomic(directory / "asset.json", asset.to_dict())
|
||||
self._write_json(directory / "asset.json", asset.to_dict())
|
||||
self.append_asset_event(
|
||||
asset.asset_id,
|
||||
event_type="asset_created",
|
||||
@@ -557,7 +672,7 @@ class MemberRepository:
|
||||
raise RepositoryError("Status issued benötigt ein zugeordnetes Mitglied.")
|
||||
changes = self._summarize_asset_changes(existing, asset)
|
||||
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
self._write_json(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
if changes:
|
||||
self.append_asset_event(
|
||||
asset.asset_id,
|
||||
@@ -597,7 +712,7 @@ class MemberRepository:
|
||||
asset.custody_type = "member"
|
||||
asset.status = "issued"
|
||||
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
self._write_json(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
self.append_asset_event(
|
||||
asset.asset_id,
|
||||
event_type="asset_issued",
|
||||
@@ -625,7 +740,7 @@ class MemberRepository:
|
||||
asset.custody_type = "club"
|
||||
asset.status = "available"
|
||||
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
self._write_json(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
self.append_asset_event(
|
||||
asset.asset_id,
|
||||
event_type="asset_returned",
|
||||
@@ -784,6 +899,7 @@ class MemberRepository:
|
||||
references=references or {},
|
||||
data=data or {},
|
||||
)
|
||||
self.assert_writable()
|
||||
path = directory / "events.jsonl"
|
||||
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
||||
@@ -839,7 +955,7 @@ class MemberRepository:
|
||||
|
||||
def save_contributions(self, member_id: str, data: ContributionData) -> None:
|
||||
self.get_member(member_id)
|
||||
write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict())
|
||||
self._write_json(self._member_path(member_id) / "contributions.json", data.to_dict())
|
||||
|
||||
def get_claim(self, member_id: str, claim_id: str) -> tuple[ContributionData, dict]:
|
||||
data = self.get_contributions(member_id)
|
||||
@@ -1975,17 +2091,15 @@ class MemberRepository:
|
||||
name: str,
|
||||
payment_deadline_days: int,
|
||||
detail: str = "",
|
||||
fee: str = "0",
|
||||
items: list[dict[str, str]] | None = None,
|
||||
channel: str = "email",
|
||||
) -> dict:
|
||||
if level < 1:
|
||||
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
|
||||
try:
|
||||
selected_fee = decimal_value(fee, "Mahngebühr")
|
||||
normalized_items = _normalize_reminder_items(items)
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if selected_fee < 0:
|
||||
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.")
|
||||
if payment_deadline_days < 1 or payment_deadline_days > 365:
|
||||
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
@@ -2025,8 +2139,8 @@ class MemberRepository:
|
||||
"payment_deadline_days": payment_deadline_days,
|
||||
"payment_deadline": None,
|
||||
"balance_snapshot": money_text(claim_balance(data, claim)),
|
||||
"fee": money_text(selected_fee),
|
||||
"fee_item_id": None,
|
||||
"items": normalized_items,
|
||||
"fee_item_ids": [],
|
||||
"document": {"path": "", "sha256": ""},
|
||||
}
|
||||
data.reminders.append(reminder)
|
||||
@@ -2036,7 +2150,10 @@ class MemberRepository:
|
||||
event_type="reminder_draft_created",
|
||||
summary=f"Mahnungsentwurf erstellt: {reminder['name']}",
|
||||
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||
data={"fee": reminder["fee"], "balance_snapshot": reminder["balance_snapshot"]},
|
||||
data={
|
||||
"fee": money_text(reminder_items_total(reminder)),
|
||||
"balance_snapshot": reminder["balance_snapshot"],
|
||||
},
|
||||
)
|
||||
return reminder
|
||||
|
||||
@@ -2048,20 +2165,27 @@ class MemberRepository:
|
||||
if claim_balance(data, claim) <= 0:
|
||||
raise RepositoryError("Die Forderung hat keinen offenen Betrag mehr.")
|
||||
now = datetime.now().astimezone()
|
||||
selected_fee = decimal_value(reminder.get("fee", "0"), "Mahngebühr")
|
||||
if selected_fee > 0 and not reminder.get("fee_item_id"):
|
||||
item = {
|
||||
"item_id": str(uuid4()),
|
||||
"type": "fee",
|
||||
"description": f"Mahngebühr Stufe {reminder.get('level', '')}",
|
||||
"quantity": "1.00",
|
||||
"unit_price": money_text(selected_fee),
|
||||
"amount": money_text(selected_fee),
|
||||
"created_at": now.isoformat(timespec="seconds"),
|
||||
}
|
||||
materialize_claim_items(claim).append(item)
|
||||
claim["amount"] = money_text(claim_total(claim))
|
||||
reminder["fee_item_id"] = item["item_id"]
|
||||
if reminder.get("items") and not reminder.get("fee_item_ids"):
|
||||
fee_item_ids = []
|
||||
for entry in reminder["items"]:
|
||||
amount = decimal_value(entry.get("amount", "0"))
|
||||
if amount <= 0:
|
||||
continue
|
||||
description = str(entry.get("description")) or f"Mahngebühr Stufe {reminder.get('level', '')}"
|
||||
item = {
|
||||
"item_id": str(uuid4()),
|
||||
"type": "fee",
|
||||
"description": description,
|
||||
"quantity": "1.00",
|
||||
"unit_price": money_text(amount),
|
||||
"amount": money_text(amount),
|
||||
"created_at": now.isoformat(timespec="seconds"),
|
||||
}
|
||||
materialize_claim_items(claim).append(item)
|
||||
fee_item_ids.append(item["item_id"])
|
||||
if fee_item_ids:
|
||||
claim["amount"] = money_text(claim_total(claim))
|
||||
reminder["fee_item_ids"] = fee_item_ids
|
||||
reminder["status"] = "sent"
|
||||
reminder["sent_at"] = now.isoformat(timespec="seconds")
|
||||
reminder["payment_deadline"] = (
|
||||
@@ -2073,7 +2197,10 @@ class MemberRepository:
|
||||
event_type="reminder_sent",
|
||||
summary=f"Mahnung als versandt markiert: {reminder.get('name', '')}",
|
||||
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||
data={"fee": money_text(selected_fee), "payment_deadline": reminder["payment_deadline"]},
|
||||
data={
|
||||
"fee": money_text(reminder_items_total(reminder)),
|
||||
"payment_deadline": reminder["payment_deadline"],
|
||||
},
|
||||
)
|
||||
return reminder
|
||||
|
||||
@@ -2092,6 +2219,97 @@ class MemberRepository:
|
||||
references={"claim_id": claim_id, "reminder_id": reminder_id},
|
||||
)
|
||||
|
||||
def edit_reminder_draft(
|
||||
self,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
reminder_id: str,
|
||||
*,
|
||||
name: str,
|
||||
payment_deadline_days: int,
|
||||
detail: str = "",
|
||||
items: list[dict[str, str]] | None = None,
|
||||
channel: str = "email",
|
||||
) -> dict:
|
||||
try:
|
||||
normalized_items = _normalize_reminder_items(items)
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if payment_deadline_days < 1 or payment_deadline_days > 365:
|
||||
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
|
||||
data, _claim = self.get_claim(member_id, claim_id)
|
||||
reminder = self._find_reminder(data, claim_id, reminder_id)
|
||||
if str(reminder.get("status", "draft")) not in {"draft", "generated"}:
|
||||
raise RepositoryError("Nur ein Entwurf kann bearbeitet werden.")
|
||||
reminder["name"] = name.strip() or reminder["name"]
|
||||
reminder["detail"] = detail.strip()
|
||||
reminder["channel"] = channel.strip() or "email"
|
||||
reminder["payment_deadline_days"] = payment_deadline_days
|
||||
reminder["items"] = normalized_items
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="reminder_draft_edited",
|
||||
summary=f"Mahnungsentwurf bearbeitet: {reminder['name']}",
|
||||
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||
data={"fee": money_text(reminder_items_total(reminder))},
|
||||
)
|
||||
return reminder
|
||||
|
||||
def revert_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
|
||||
"""Undoes mark_reminder_sent: removes the fee items it booked onto the claim
|
||||
and puts the reminder back into "draft" so its text/items can be edited and it
|
||||
can be sent again -- e.g. after generating a Mahnung email and noticing the
|
||||
wording needs a fix. Refuses if a later Mahnstufe already exists (would break
|
||||
the level ordering) or if payments already booked against the claim would no
|
||||
longer fit once the fee is removed."""
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
reminder = self._find_reminder(data, claim_id, reminder_id)
|
||||
if str(reminder.get("status", "")) != "sent":
|
||||
raise RepositoryError("Nur eine bereits versandte Mahnung kann zurückgesetzt werden.")
|
||||
if any(
|
||||
str(item.get("claim_id", "")) == claim_id
|
||||
and int(item.get("level", 0)) > int(reminder.get("level", 0))
|
||||
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
||||
for item in data.reminders
|
||||
):
|
||||
raise RepositoryError(
|
||||
"Es existiert bereits eine höhere Mahnstufe; diese Mahnung kann nicht "
|
||||
"zurückgesetzt werden."
|
||||
)
|
||||
fee_item_ids = set(reminder.get("fee_item_ids") or [])
|
||||
if fee_item_ids:
|
||||
removed_total = sum(
|
||||
(
|
||||
decimal_value(item.get("amount", "0"))
|
||||
for item in claim_items(claim)
|
||||
if str(item.get("item_id", "")) in fee_item_ids
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
new_total = claim_total(claim) - removed_total
|
||||
if claim_settled_total(data, claim) > new_total:
|
||||
raise RepositoryError(
|
||||
"Für diese Forderung wurden bereits Zahlungen verbucht, die die "
|
||||
"Mahngebühr abdecken; der Versand kann nicht zurückgesetzt werden."
|
||||
)
|
||||
claim["items"] = [
|
||||
item for item in claim_items(claim) if str(item.get("item_id", "")) not in fee_item_ids
|
||||
]
|
||||
claim["amount"] = money_text(claim_total(claim))
|
||||
reminder["status"] = "draft"
|
||||
reminder["sent_at"] = None
|
||||
reminder["payment_deadline"] = None
|
||||
reminder["fee_item_ids"] = []
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="reminder_sent_reverted",
|
||||
summary=f"Versand zurückgesetzt: {reminder.get('name', '')}",
|
||||
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||
)
|
||||
return reminder
|
||||
|
||||
def register_reminder_document(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -2260,6 +2478,7 @@ class MemberRepository:
|
||||
references=references or {},
|
||||
data=data or {},
|
||||
)
|
||||
self.assert_writable()
|
||||
path = directory / "events.jsonl"
|
||||
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
||||
@@ -2366,12 +2585,12 @@ class MemberRepository:
|
||||
def refresh_member_record_hashes(self, member_id: str) -> None:
|
||||
member = self.get_member(member_id)
|
||||
contributions = self.get_contributions(member_id)
|
||||
write_json_atomic(self._member_path(member_id) / "member.json", member.to_dict())
|
||||
write_json_atomic(self._member_path(member_id) / "contributions.json", contributions.to_dict())
|
||||
self._write_json(self._member_path(member_id) / "member.json", member.to_dict())
|
||||
self._write_json(self._member_path(member_id) / "contributions.json", contributions.to_dict())
|
||||
|
||||
def refresh_asset_record_hashes(self, asset_id: str) -> None:
|
||||
asset = self.get_asset(asset_id)
|
||||
write_json_atomic(self._asset_path(asset_id) / "asset.json", asset.to_dict())
|
||||
self._write_json(self._asset_path(asset_id) / "asset.json", asset.to_dict())
|
||||
|
||||
def get_member_number_policy(self) -> dict[str, str]:
|
||||
try:
|
||||
@@ -2408,7 +2627,227 @@ class MemberRepository:
|
||||
"allocation_strategy": allocation_strategy,
|
||||
}
|
||||
config.setdefault("member_number_sequences", {})
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
self._write_json(self.root / "repository.json", config)
|
||||
|
||||
def get_reminder_policy(self) -> dict:
|
||||
config = self.get_configuration()
|
||||
policy = config.get("reminder_policy") or {}
|
||||
levels = sorted(
|
||||
(
|
||||
{
|
||||
"level": int(entry.get("level", 0)),
|
||||
"name": str(entry.get("name", "")),
|
||||
"fee": money_text(entry.get("fee", "0")),
|
||||
"payment_deadline_days": int(entry.get("payment_deadline_days", 14)),
|
||||
}
|
||||
for entry in policy.get("levels") or []
|
||||
),
|
||||
key=lambda entry: entry["level"],
|
||||
)
|
||||
# Repositories created before this setting existed have no "standard_fee_items"
|
||||
# key at all -- fall back to the built-in defaults rather than an empty list, but
|
||||
# once the board has actually saved (and possibly emptied) the list, respect that.
|
||||
if "standard_fee_items" in policy:
|
||||
standard_items_raw = policy.get("standard_fee_items") or []
|
||||
else:
|
||||
standard_items_raw = DEFAULT_CONFIGURATION["reminder_policy"]["standard_fee_items"]
|
||||
standard_fee_items = [
|
||||
{
|
||||
"description": str(entry.get("description", "")),
|
||||
"default_amount": money_text(entry.get("default_amount", "0")),
|
||||
}
|
||||
for entry in standard_items_raw
|
||||
]
|
||||
return {
|
||||
"grace_days_after_due": int(policy.get("grace_days_after_due", 7)),
|
||||
"levels": levels,
|
||||
"standard_fee_items": standard_fee_items,
|
||||
}
|
||||
|
||||
def save_reminder_policy(
|
||||
self,
|
||||
*,
|
||||
grace_days_after_due: int,
|
||||
levels: list[dict[str, str]],
|
||||
standard_fee_items: list[dict[str, str]],
|
||||
) -> None:
|
||||
if grace_days_after_due < 0 or grace_days_after_due > 365:
|
||||
raise RepositoryError("Die Kulanzfrist muss zwischen 0 und 365 Tagen liegen.")
|
||||
normalized_levels = []
|
||||
for index, entry in enumerate(levels, start=1):
|
||||
name = str(entry.get("name", "")).strip()
|
||||
if not name:
|
||||
raise RepositoryError(f"Mahnstufe {index} benötigt einen Namen.")
|
||||
try:
|
||||
fee = decimal_value(entry.get("fee", "0"), "Gebühr")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if fee < 0:
|
||||
raise RepositoryError("Die Gebühr darf nicht negativ sein.")
|
||||
try:
|
||||
deadline_days = int(entry.get("payment_deadline_days", 14))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RepositoryError("Die Zahlungsfrist muss eine ganze Zahl sein.") from exc
|
||||
if deadline_days < 1 or deadline_days > 365:
|
||||
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
|
||||
# Levels are always renumbered 1..N by row order -- picking arbitrary level
|
||||
# numbers would break the sequencing checks in create_reminder_draft.
|
||||
normalized_levels.append(
|
||||
{"level": index, "name": name, "fee": money_text(fee), "payment_deadline_days": deadline_days}
|
||||
)
|
||||
if not normalized_levels:
|
||||
raise RepositoryError("Mindestens eine Mahnstufe ist erforderlich.")
|
||||
normalized_items = []
|
||||
for entry in standard_fee_items:
|
||||
description = str(entry.get("description", "")).strip()
|
||||
if not description:
|
||||
raise RepositoryError("Jede Standardposition benötigt eine Beschreibung.")
|
||||
try:
|
||||
amount = decimal_value(entry.get("default_amount", "0"), "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if amount < 0:
|
||||
raise RepositoryError("Der Standardbetrag darf nicht negativ sein.")
|
||||
normalized_items.append({"description": description, "default_amount": money_text(amount)})
|
||||
config = self.get_configuration()
|
||||
config["reminder_policy"] = {
|
||||
"grace_days_after_due": grace_days_after_due,
|
||||
"levels": normalized_levels,
|
||||
"standard_fee_items": normalized_items,
|
||||
}
|
||||
self._write_json(self.root / "repository.json", config)
|
||||
|
||||
def get_email_settings(self) -> dict:
|
||||
config = self.get_configuration()
|
||||
settings = config.get("email_settings") or {}
|
||||
defaults = DEFAULT_CONFIGURATION["email_settings"]
|
||||
delivery_mode = str(settings.get("delivery_mode", defaults["delivery_mode"]))
|
||||
if delivery_mode not in {"local", "send", "drafts", "ask"}:
|
||||
delivery_mode = defaults["delivery_mode"]
|
||||
smtp_security = str(settings.get("smtp_security", defaults["smtp_security"]))
|
||||
if smtp_security not in {"starttls", "ssl", "none"}:
|
||||
smtp_security = defaults["smtp_security"]
|
||||
imap_security = str(settings.get("imap_security", defaults["imap_security"]))
|
||||
if imap_security not in {"starttls", "ssl", "none"}:
|
||||
imap_security = defaults["imap_security"]
|
||||
try:
|
||||
smtp_port = int(settings.get("smtp_port", defaults["smtp_port"]))
|
||||
except (TypeError, ValueError):
|
||||
smtp_port = defaults["smtp_port"]
|
||||
try:
|
||||
imap_port = int(settings.get("imap_port", defaults["imap_port"]))
|
||||
except (TypeError, ValueError):
|
||||
imap_port = defaults["imap_port"]
|
||||
return {
|
||||
"delivery_mode": delivery_mode,
|
||||
"smtp_host": str(settings.get("smtp_host", "")),
|
||||
"smtp_port": smtp_port,
|
||||
"smtp_security": smtp_security,
|
||||
"smtp_username": str(settings.get("smtp_username", "")),
|
||||
"smtp_password": str(settings.get("smtp_password", "")),
|
||||
"imap_host": str(settings.get("imap_host", "")),
|
||||
"imap_port": imap_port,
|
||||
"imap_security": imap_security,
|
||||
"imap_username": str(settings.get("imap_username", "")),
|
||||
"imap_password": str(settings.get("imap_password", "")),
|
||||
"imap_drafts_folder": str(
|
||||
settings.get("imap_drafts_folder", defaults["imap_drafts_folder"])
|
||||
),
|
||||
"imap_sent_enabled": bool(settings.get("imap_sent_enabled", defaults["imap_sent_enabled"])),
|
||||
"imap_sent_folder": str(settings.get("imap_sent_folder", defaults["imap_sent_folder"])),
|
||||
}
|
||||
|
||||
def save_email_settings(
|
||||
self,
|
||||
*,
|
||||
delivery_mode: str,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_security: str,
|
||||
smtp_username: str,
|
||||
smtp_password: str,
|
||||
imap_host: str,
|
||||
imap_port: int,
|
||||
imap_security: str,
|
||||
imap_username: str,
|
||||
imap_password: str,
|
||||
imap_drafts_folder: str,
|
||||
imap_sent_enabled: bool = False,
|
||||
imap_sent_folder: str = "",
|
||||
) -> None:
|
||||
if delivery_mode not in {"local", "send", "drafts", "ask"}:
|
||||
raise RepositoryError("Ungültiger Versandmodus.")
|
||||
if smtp_security not in {"starttls", "ssl", "none"}:
|
||||
raise RepositoryError("Ungültige SMTP-Verschlüsselung.")
|
||||
if imap_security not in {"starttls", "ssl", "none"}:
|
||||
raise RepositoryError("Ungültige IMAP-Verschlüsselung.")
|
||||
if delivery_mode in {"send", "ask"} and not smtp_host.strip():
|
||||
raise RepositoryError("Für den direkten Versand ist ein SMTP-Server erforderlich.")
|
||||
if delivery_mode in {"drafts", "ask"} and not imap_host.strip():
|
||||
raise RepositoryError("Für die Ablage als Entwurf ist ein IMAP-Server erforderlich.")
|
||||
if imap_sent_enabled and not imap_host.strip():
|
||||
raise RepositoryError(
|
||||
"Für die Ablage im Gesendet-Ordner ist ein IMAP-Server erforderlich."
|
||||
)
|
||||
for label, port in (("Der SMTP-Port", smtp_port), ("Der IMAP-Port", imap_port)):
|
||||
if port < 1 or port > 65535:
|
||||
raise RepositoryError(f"{label} muss zwischen 1 und 65535 liegen.")
|
||||
config = self.get_configuration()
|
||||
config["email_settings"] = {
|
||||
"delivery_mode": delivery_mode,
|
||||
"smtp_host": smtp_host.strip(),
|
||||
"smtp_port": int(smtp_port),
|
||||
"smtp_security": smtp_security,
|
||||
"smtp_username": smtp_username.strip(),
|
||||
"smtp_password": smtp_password,
|
||||
"imap_host": imap_host.strip(),
|
||||
"imap_port": int(imap_port),
|
||||
"imap_security": imap_security,
|
||||
"imap_username": imap_username.strip(),
|
||||
"imap_password": imap_password,
|
||||
"imap_drafts_folder": imap_drafts_folder.strip() or "INBOX.Entwürfe",
|
||||
"imap_sent_enabled": bool(imap_sent_enabled),
|
||||
"imap_sent_folder": imap_sent_folder.strip() or "INBOX.Sent",
|
||||
}
|
||||
self._write_json(self.root / "repository.json", config)
|
||||
|
||||
@property
|
||||
def mail_templates_root(self) -> Path:
|
||||
return self.root / "templates" / "mail"
|
||||
|
||||
def get_mail_template(self, key: str) -> MailTemplate:
|
||||
"""Reads the store's copy of a mail template. Stores created before mail
|
||||
templates existed (or with a deleted file) fall back to the shipped default
|
||||
and get the file written back, so the board always has an editable copy -- on a
|
||||
read-only store the fallback is simply used without storing it."""
|
||||
spec = template_spec(key)
|
||||
path = self.mail_templates_root / spec.filename
|
||||
try:
|
||||
return parse_mail_template(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
template = default_mail_template(key)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_text_atomic(path, serialize_mail_template(template.subject, template.body))
|
||||
except OSError:
|
||||
pass
|
||||
return template
|
||||
|
||||
def save_mail_template(self, key: str, *, subject: str, body: str) -> None:
|
||||
self.assert_writable()
|
||||
spec = template_spec(key)
|
||||
try:
|
||||
validate_mail_template(key, subject, body)
|
||||
except MailTemplateError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
path = self.mail_templates_root / spec.filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_text_atomic(path, serialize_mail_template(subject, body))
|
||||
|
||||
def reset_mail_template(self, key: str) -> MailTemplate:
|
||||
template = default_mail_template(key)
|
||||
self.save_mail_template(key, subject=template.subject, body=template.body)
|
||||
return template
|
||||
|
||||
def save_organization(self, values: dict[str, str]) -> None:
|
||||
organization = {key: str(value).strip() for key, value in values.items()}
|
||||
@@ -2420,7 +2859,7 @@ class MemberRepository:
|
||||
raise RepositoryError("Der Vereinsname ist erforderlich.")
|
||||
config = self.get_configuration()
|
||||
config["organization"] = organization
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
self._write_json(self.root / "repository.json", config)
|
||||
|
||||
def preview_member_number(
|
||||
self, pattern: str | None = None, allocation_strategy: str | None = None
|
||||
@@ -2466,7 +2905,7 @@ class MemberRepository:
|
||||
sequences = {}
|
||||
config["member_number_sequences"] = sequences
|
||||
sequences[pattern] = next_value
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
self._write_json(self.root / "repository.json", config)
|
||||
return member_number
|
||||
|
||||
def _next_available_member_number(
|
||||
@@ -2492,6 +2931,7 @@ class MemberRepository:
|
||||
|
||||
@contextmanager
|
||||
def _member_number_lock(self):
|
||||
self.assert_writable()
|
||||
lock_path = self.root / ".member-number.lock"
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||
@@ -2644,6 +3084,19 @@ def _contribution_override_summary(override: dict) -> str:
|
||||
return f"{value_text} ({period})"
|
||||
|
||||
|
||||
def _normalize_reminder_items(items: list[dict[str, str]] | None) -> list[dict[str, str]]:
|
||||
normalized = []
|
||||
for entry in items or []:
|
||||
description = str(entry.get("description", "")).strip()
|
||||
if not description:
|
||||
raise ValueError("Jede Position benötigt eine Beschreibung.")
|
||||
amount = decimal_value(entry.get("amount", "0"), "Betrag")
|
||||
if amount < 0:
|
||||
raise ValueError("Der Betrag darf nicht negativ sein.")
|
||||
normalized.append({"description": description, "amount": money_text(amount)})
|
||||
return normalized
|
||||
|
||||
|
||||
def _dunning_hold_is_active(claim: dict) -> bool:
|
||||
hold = claim.get("dunning_hold") or {}
|
||||
if not hold.get("active"):
|
||||
|
||||
@@ -404,7 +404,11 @@ class AssetTab(ttk.Frame):
|
||||
self._confirm_integrity_and_then(self._refresh_hashes_only)
|
||||
|
||||
def _refresh_hashes_only(self) -> None:
|
||||
self.repository.refresh_asset_record_hashes(self.asset_id)
|
||||
try:
|
||||
self.repository.refresh_asset_record_hashes(self.asset_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
|
||||
return
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
|
||||
+332
-55
@@ -19,11 +19,13 @@ from ccma.domain.contributions import (
|
||||
decimal_value,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||
from ccma.ui.labels import (
|
||||
CLAIM_ITEM_TYPE_LABELS,
|
||||
REMINDER_CHANNEL_LABELS,
|
||||
@@ -166,6 +168,10 @@ class ClaimTab(ttk.Frame):
|
||||
state="disabled",
|
||||
)
|
||||
self.email_reminder_button.pack(side="right", padx=(8, 0))
|
||||
self.edit_reminder_button = ttk.Button(
|
||||
buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled"
|
||||
)
|
||||
self.edit_reminder_button.pack(side="right", padx=(8, 0))
|
||||
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
|
||||
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
|
||||
side="right", padx=(0, 8)
|
||||
@@ -348,7 +354,7 @@ class ClaimTab(ttk.Frame):
|
||||
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
|
||||
"",
|
||||
"",
|
||||
f"{reminder.get('fee', '0.00')} EUR",
|
||||
f"{money_text(reminder_items_total(reminder))} EUR",
|
||||
display_label(REMINDER_STATUS_LABELS, status),
|
||||
" · ".join(
|
||||
part
|
||||
@@ -437,6 +443,9 @@ class ClaimTab(ttk.Frame):
|
||||
self.send_reminder_button.configure(state=state)
|
||||
self.email_reminder_button.configure(state=state)
|
||||
self.discard_reminder_button.configure(state=state)
|
||||
# Sent reminders can also be edited (revert-then-edit), just not cancelled ones.
|
||||
can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled")
|
||||
self.edit_reminder_button.configure(state="normal" if can_edit else "disabled")
|
||||
|
||||
def _email_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
@@ -466,6 +475,32 @@ class ClaimTab(ttk.Frame):
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _edit_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder:
|
||||
return
|
||||
if str(reminder.get("status", "")) == "sent":
|
||||
if not messagebox.askyesno(
|
||||
"Versand zurücksetzen",
|
||||
"Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand "
|
||||
"zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung "
|
||||
"entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung "
|
||||
"erneut versandt werden. Fortfahren?",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
reminder = self.repository.revert_reminder_sent(
|
||||
self.member_id, self.claim_id, str(reminder["reminder_id"])
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self)
|
||||
return
|
||||
self.refresh()
|
||||
ReminderDialog(
|
||||
self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder
|
||||
)
|
||||
|
||||
def _discard_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder or not messagebox.askyesno(
|
||||
@@ -867,67 +902,297 @@ class AllocateCreditDialog(_Dialog):
|
||||
|
||||
|
||||
class ReminderDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
||||
"""Drafts the next reminder in the sequence for a claim. The level is computed
|
||||
automatically (first not-yet-used level), and a "Vorlage" picks sensible defaults
|
||||
for name/deadline/fee items -- the matching dunning-policy level if one exists,
|
||||
a "Rücklastschrift" preset for a bounced direct debit (which occupies the same
|
||||
level slot as a regular reminder, since it's still the member's own failure to
|
||||
provide cover/valid bank details), or a blank template for manual entry. The fee
|
||||
items table itself is always free to edit no matter which preset was picked."""
|
||||
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved, *, reminder: dict | None = None):
|
||||
super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved)
|
||||
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
|
||||
# of freely-editable fee items whose content varies a lot -- make it resizable
|
||||
# so a long/multi-item Mahnung doesn't get cramped.
|
||||
self.resizable(True, True)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
policy = repository.get_configuration().get("reminder_policy") or {}
|
||||
levels = policy.get("levels") or [
|
||||
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
|
||||
]
|
||||
self.definition_by_label = {
|
||||
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
|
||||
}
|
||||
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
|
||||
self.detail_var = tk.StringVar()
|
||||
self.fee_var = tk.StringVar()
|
||||
self.deadline_var = tk.StringVar()
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
self.editing_reminder = reminder
|
||||
if reminder:
|
||||
self.level = int(reminder.get("level", 1))
|
||||
else:
|
||||
data, _claim = repository.get_claim(member_id, claim_id)
|
||||
existing_levels = {
|
||||
int(item.get("level", 0))
|
||||
for item in data.reminders
|
||||
if str(item.get("claim_id", "")) == claim_id
|
||||
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
||||
}
|
||||
self.level = max(existing_levels, default=0) + 1
|
||||
self.policy = repository.get_reminder_policy()
|
||||
self.items: list[dict[str, str]] = []
|
||||
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
self.frame.rowconfigure(6, weight=1)
|
||||
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
level_combo = ttk.Combobox(
|
||||
ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid(
|
||||
row=0, column=1, sticky="w", pady=5
|
||||
)
|
||||
|
||||
self.preset_by_label = self._build_presets()
|
||||
default_preset = (
|
||||
next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label))
|
||||
)
|
||||
self.preset_var = tk.StringVar(value=default_preset)
|
||||
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
preset_combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.level_var,
|
||||
values=list(self.definition_by_label),
|
||||
textvariable=self.preset_var,
|
||||
values=list(self.preset_by_label),
|
||||
state="readonly",
|
||||
width=38,
|
||||
width=55,
|
||||
)
|
||||
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
|
||||
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
|
||||
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
|
||||
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
|
||||
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
|
||||
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.name_var, width=64).grid(
|
||||
row=2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
|
||||
self.detail_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Details").grid(row=3, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=64).grid(
|
||||
row=3, column=1, sticky="ew", pady=5
|
||||
)
|
||||
|
||||
self.deadline_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=5, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.channel_var,
|
||||
values=list(REMINDER_CHANNEL_LABELS.values()),
|
||||
state="readonly",
|
||||
).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
self._load_definition()
|
||||
self._buttons(5, self._save)
|
||||
).grid(row=5, column=1, sticky="ew", pady=5)
|
||||
|
||||
def _load_definition(self) -> None:
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
self.fee_var.set(str(definition.get("fee", "0.00")))
|
||||
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
|
||||
self._build_items_table(row=6)
|
||||
if reminder:
|
||||
self.name_var.set(str(reminder.get("name", "")))
|
||||
self.detail_var.set(str(reminder.get("detail", "")))
|
||||
self.deadline_var.set(str(reminder.get("payment_deadline_days", 14)))
|
||||
self.channel_var.set(
|
||||
display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email")))
|
||||
)
|
||||
self.items = [dict(item) for item in reminder.get("items") or []]
|
||||
self._refresh_items()
|
||||
else:
|
||||
self._apply_preset()
|
||||
self._buttons(7, self._save)
|
||||
# Size to fit the fully built content (incl. the preset-populated items table)
|
||||
# instead of whatever size Tk guessed before everything was in place -- the
|
||||
# base dialog sets resizable(False, False) before this subclass's content
|
||||
# exists, so an explicit geometry() is needed or the window stays clipped.
|
||||
# The floor of 700 keeps the dialog comfortably wide even for a short name/
|
||||
# detail; the user can still grow it further since it's resizable now.
|
||||
self.update_idletasks()
|
||||
width = max(700, self.winfo_reqwidth())
|
||||
height = self.winfo_reqheight()
|
||||
self.geometry(f"{width}x{height}")
|
||||
self.minsize(width, height)
|
||||
|
||||
def _build_presets(self) -> dict[str, dict]:
|
||||
presets: dict[str, dict] = {}
|
||||
matching = next((lvl for lvl in self.policy["levels"] if lvl["level"] == self.level), None)
|
||||
if matching:
|
||||
fee = decimal_value(matching["fee"])
|
||||
presets[f"Stufe {self.level}: {matching['name']}"] = {
|
||||
"name": matching["name"],
|
||||
"detail": "",
|
||||
"payment_deadline_days": matching["payment_deadline_days"],
|
||||
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
|
||||
}
|
||||
failed_debit_amount = self._default_amount_for("Rücklastschrift")
|
||||
presets[f"Stufe {self.level}: Rücklastschrift"] = {
|
||||
"name": "Rücklastschrift",
|
||||
# Prefilled so the member sees why they're being charged; the board can
|
||||
# still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...)
|
||||
# right here since "Details" goes straight into the reminder email as-is.
|
||||
"detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.",
|
||||
"payment_deadline_days": 14,
|
||||
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
||||
}
|
||||
presets["Frei (eigene Eingabe)"] = {
|
||||
"name": "", "detail": "", "payment_deadline_days": 14, "items": []
|
||||
}
|
||||
return presets
|
||||
|
||||
def _default_amount_for(self, needle: str) -> str:
|
||||
for entry in self.policy["standard_fee_items"]:
|
||||
if needle.casefold() in str(entry.get("description", "")).casefold():
|
||||
return str(entry.get("default_amount", "0.00"))
|
||||
return "0.00"
|
||||
|
||||
def _apply_preset(self) -> None:
|
||||
preset = self.preset_by_label[self.preset_var.get()]
|
||||
self.name_var.set(preset["name"])
|
||||
self.detail_var.set(preset["detail"])
|
||||
self.deadline_var.set(str(preset["payment_deadline_days"]))
|
||||
self.items = [dict(item) for item in preset["items"]]
|
||||
self._refresh_items()
|
||||
|
||||
def _build_items_table(self, row: int) -> None:
|
||||
table_frame = ttk.LabelFrame(self.frame, text="Positionen", padding=8)
|
||||
table_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(8, 0))
|
||||
table_frame.columnconfigure(0, weight=1)
|
||||
table_frame.rowconfigure(0, weight=1)
|
||||
amount_column_width = 100
|
||||
self.items_tree = ttk.Treeview(
|
||||
table_frame, columns=("description", "amount"), show="headings", height=4, selectmode="browse"
|
||||
)
|
||||
self.items_tree.heading("description", text="Beschreibung")
|
||||
self.items_tree.heading("amount", text="Betrag")
|
||||
self.items_tree.column("description", width=320, anchor="w")
|
||||
self.items_tree.column("amount", width=amount_column_width, anchor="w", stretch=False)
|
||||
self.items_tree.grid(row=0, column=0, sticky="nsew")
|
||||
self.items_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_item())
|
||||
|
||||
# Edit row lines up under the table's own columns: "Beschreibung" stretches to
|
||||
# fill remaining space just like that Treeview column does, while "Betrag" sits
|
||||
# in a frame pinned to the exact same pixel width as the fixed "amount" column
|
||||
# above it (Entry's width= is in characters, not pixels, so a fixed-size frame
|
||||
# is what gets a genuinely exact match instead of an approximate ratio).
|
||||
edit_row = ttk.Frame(table_frame)
|
||||
edit_row.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
edit_row.columnconfigure(0, weight=1)
|
||||
edit_row.columnconfigure(1, weight=0)
|
||||
self.item_description_var = tk.StringVar()
|
||||
description_combo = ttk.Combobox(
|
||||
edit_row,
|
||||
textvariable=self.item_description_var,
|
||||
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
|
||||
)
|
||||
description_combo.grid(row=0, column=0, sticky="ew", padx=(0, 8))
|
||||
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
|
||||
amount_frame = ttk.Frame(edit_row, width=amount_column_width)
|
||||
amount_frame.grid(row=0, column=1, sticky="ns")
|
||||
amount_frame.pack_propagate(False)
|
||||
self.item_amount_var = tk.StringVar()
|
||||
ttk.Entry(amount_frame, textvariable=self.item_amount_var).pack(fill="both", expand=True)
|
||||
|
||||
buttons_row = ttk.Frame(table_frame)
|
||||
buttons_row.grid(row=2, column=0, sticky="w", pady=(8, 0))
|
||||
ttk.Button(buttons_row, text="Position hinzufügen", command=self._add_item).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons_row, text="Aktualisieren", command=self._update_item).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons_row, text="Position entfernen", command=self._remove_item).pack(side="left")
|
||||
|
||||
def _prefill_item_amount(self) -> None:
|
||||
description = self.item_description_var.get()
|
||||
for entry in self.policy["standard_fee_items"]:
|
||||
if str(entry.get("description", "")) == description:
|
||||
self.item_amount_var.set(str(entry.get("default_amount", "0.00")))
|
||||
return
|
||||
|
||||
def _refresh_items(self) -> None:
|
||||
self.items_tree.delete(*self.items_tree.get_children())
|
||||
for index, item in enumerate(self.items):
|
||||
self.items_tree.insert(
|
||||
"", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR")
|
||||
)
|
||||
|
||||
def _load_selected_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
item = self.items[int(selected[0])]
|
||||
self.item_description_var.set(item["description"])
|
||||
self.item_amount_var.set(item["amount"])
|
||||
|
||||
def _add_item(self) -> None:
|
||||
description = self.item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.item_amount_var.get(), "Betrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.items.append({"description": description, "amount": money_text(amount)})
|
||||
self.item_description_var.set("")
|
||||
self.item_amount_var.set("")
|
||||
self._refresh_items()
|
||||
|
||||
def _update_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror(
|
||||
"Auswahl fehlt", "Bitte die zu ändernde Position auswählen.", parent=self
|
||||
)
|
||||
return
|
||||
description = self.item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.item_amount_var.get(), "Betrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.items[int(selected[0])] = {"description": description, "amount": money_text(amount)}
|
||||
self._refresh_items()
|
||||
|
||||
def _remove_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.items[int(selected[0])]
|
||||
self.item_description_var.set("")
|
||||
self.item_amount_var.set("")
|
||||
self._refresh_items()
|
||||
|
||||
def _save(self):
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
try:
|
||||
self.repository.create_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=int(definition.get("level", 1)),
|
||||
name=str(definition.get("name", "Mahnung")),
|
||||
payment_deadline_days=int(self.deadline_var.get()),
|
||||
detail=self.detail_var.get(),
|
||||
fee=self.fee_var.get(),
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
deadline_days = int(self.deadline_var.get())
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Ungültige Frist", "Die Zahlungsfrist muss eine ganze Zahl sein.", parent=self
|
||||
)
|
||||
return
|
||||
try:
|
||||
if self.editing_reminder:
|
||||
self.repository.edit_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
str(self.editing_reminder["reminder_id"]),
|
||||
name=self.name_var.get(),
|
||||
payment_deadline_days=deadline_days,
|
||||
detail=self.detail_var.get(),
|
||||
items=self.items,
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
)
|
||||
else:
|
||||
self.repository.create_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=self.level,
|
||||
name=self.name_var.get(),
|
||||
payment_deadline_days=deadline_days,
|
||||
detail=self.detail_var.get(),
|
||||
items=self.items,
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
)
|
||||
except (ValueError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
@@ -994,22 +1259,29 @@ class ReminderMailDialog(tk.Toplevel):
|
||||
self.grab_set()
|
||||
|
||||
def _generate(self) -> None:
|
||||
selected = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Mahnung als Thunderbird-Entwurf speichern",
|
||||
defaultextension=".eml",
|
||||
initialfile=f"{self.reminder_name}.eml",
|
||||
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not selected:
|
||||
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||
if delivery_mode is None:
|
||||
return
|
||||
output_path = None
|
||||
if delivery_mode == "local":
|
||||
selected = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Mahnung als Thunderbird-Entwurf speichern",
|
||||
defaultextension=".eml",
|
||||
initialfile=f"{self.reminder_name}.eml",
|
||||
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
output_path = selected
|
||||
try:
|
||||
result = generate_and_send_reminder_mail(
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
self.reminder_id,
|
||||
output_path=selected,
|
||||
delivery_mode=delivery_mode,
|
||||
output_path=output_path,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
@@ -1017,9 +1289,14 @@ class ReminderMailDialog(tk.Toplevel):
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
outcome = {
|
||||
"local": f"gespeichert:\n{result.export_path}",
|
||||
"send": "direkt versandt.",
|
||||
"drafts": "als Entwurf im Postfach abgelegt.",
|
||||
}[delivery_mode]
|
||||
messagebox.showinfo(
|
||||
"Mahnung erzeugt",
|
||||
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
|
||||
f"Die E-Mail wurde {outcome}\n\nDie Mahnung wurde als versandt gebucht.",
|
||||
parent=self,
|
||||
)
|
||||
self.destroy()
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default as default_policy
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
|
||||
from ccma.services.member_data_mail import (
|
||||
data_review_mail_bytes,
|
||||
data_review_recipients,
|
||||
generate_data_review_mails,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||
|
||||
|
||||
class DataReviewMailDialog(tk.Toplevel):
|
||||
"""Asks the members to check the data the club stores about them. Unlike the
|
||||
other mails this one goes out to everybody at once, so the recipients are picked
|
||||
from a list every member appears in -- with the active ones preselected."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
on_sent: Callable[[], None] | None = None,
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.on_sent = on_sent
|
||||
self.members: list[Member] = []
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
|
||||
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||
self.summary_var = tk.StringVar()
|
||||
self.warning_var = tk.StringVar()
|
||||
self.title("Datenüberprüfung anfragen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.geometry("940x620")
|
||||
self.minsize(760, 480)
|
||||
self._build_ui()
|
||||
self._refresh_members()
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(3, weight=1)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Bittet die ausgewählten Mitglieder, die über sie gespeicherten Daten zu "
|
||||
"prüfen. Jede Mail listet den Datensatz genau eines Mitglieds auf; die IBAN "
|
||||
"erscheint dabei nur mit den letzten vier Stellen. Betreff und Text stammen "
|
||||
"aus der Vorlage unter Optionen → E-Mail-Vorlagen."
|
||||
),
|
||||
wraplength=880,
|
||||
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Vorausgewählt sind alle aktiven Mitgliedschaften mit E-Mail-Adresse – "
|
||||
"einzelne mit Strg+Klick ab- oder zusätzlich auswählen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=880,
|
||||
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
|
||||
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||
row=2, column=0, sticky="w", padx=14, pady=(0, 6)
|
||||
)
|
||||
table = ttk.Frame(self)
|
||||
table.grid(row=3, column=0, sticky="nsew", padx=14)
|
||||
table.columnconfigure(0, weight=1)
|
||||
table.rowconfigure(0, weight=1)
|
||||
self.table = ttk.Treeview(
|
||||
table,
|
||||
columns=("number", "name", "status", "email"),
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
)
|
||||
for key, title, width, stretch in (
|
||||
("number", "Nummer", 90, False),
|
||||
("name", "Name", 220, True),
|
||||
("status", "Status", 200, False),
|
||||
("email", "E-Mail", 260, True),
|
||||
):
|
||||
self.table.heading(key, text=title)
|
||||
self.table.column(key, width=width, anchor="w", stretch=stretch)
|
||||
self.table.grid(row=0, column=0, sticky="nsew")
|
||||
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
|
||||
scroll.grid(row=0, column=1, sticky="ns")
|
||||
self.table.configure(yscrollcommand=scroll.set)
|
||||
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
|
||||
fields = ttk.Frame(self)
|
||||
fields.grid(row=4, column=0, sticky="ew", padx=14, pady=(10, 0))
|
||||
fields.columnconfigure(1, weight=1)
|
||||
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
|
||||
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
|
||||
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
|
||||
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||
|
||||
footer = ttk.Frame(self, padding=14)
|
||||
footer.grid(row=5, column=0, sticky="ew")
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(footer, textvariable=self.warning_var, style="Muted.TLabel", wraplength=520).grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
ttk.Button(footer, text="Abbrechen", command=self.destroy).grid(row=0, column=1, padx=(8, 0))
|
||||
ttk.Button(footer, text="Vorschau", command=self._preview).grid(row=0, column=2, padx=(8, 0))
|
||||
ttk.Button(
|
||||
footer, text="Mails erzeugen", style="Accent.TButton", command=self._generate
|
||||
).grid(row=0, column=3, padx=(8, 0))
|
||||
|
||||
def _refresh_members(self) -> None:
|
||||
try:
|
||||
self.members = self.repository.list_members()
|
||||
preselected = {
|
||||
member.member_id for member in data_review_recipients(self.repository)
|
||||
}
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Mitglieder konnten nicht gelesen werden", str(exc), parent=self)
|
||||
return
|
||||
self.table.delete(*self.table.get_children())
|
||||
for index, member in enumerate(self.members):
|
||||
self.table.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(
|
||||
member.member_number,
|
||||
member.display_name,
|
||||
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
member.email or "—",
|
||||
),
|
||||
)
|
||||
self.table.selection_set(
|
||||
[
|
||||
str(index)
|
||||
for index, member in enumerate(self.members)
|
||||
if member.member_id in preselected
|
||||
]
|
||||
)
|
||||
without_email = [member for member in self.members if not member.email.strip()]
|
||||
self.warning_var.set(
|
||||
f"{len(without_email)} Mitglied(er) ohne E-Mail-Adresse werden übersprungen."
|
||||
if without_email
|
||||
else ""
|
||||
)
|
||||
self._update_summary()
|
||||
|
||||
def _selected_members(self) -> list[Member]:
|
||||
return [self.members[int(iid)] for iid in sorted(self.table.selection(), key=int)]
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
selected = self._selected_members()
|
||||
reachable = [member for member in selected if member.email.strip()]
|
||||
self.summary_var.set(
|
||||
f"{len(selected)} von {len(self.members)} Mitgliedern ausgewählt – "
|
||||
f"{len(reachable)} Mail(s) werden erzeugt"
|
||||
)
|
||||
|
||||
def _preview(self) -> None:
|
||||
selected = self._selected_members()
|
||||
member = next((item for item in selected if item.email.strip()), None)
|
||||
if member is None:
|
||||
messagebox.showinfo(
|
||||
"Keine Vorschau möglich",
|
||||
"Bitte mindestens ein Mitglied mit E-Mail-Adresse auswählen.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
try:
|
||||
content = data_review_mail_bytes(
|
||||
self.repository,
|
||||
member=member,
|
||||
organization=self.repository.get_configuration().get("organization") or {},
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
|
||||
return
|
||||
message = BytesParser(policy=default_policy).parsebytes(content)
|
||||
window = tk.Toplevel(self)
|
||||
window.title(f"Vorschau für {member.display_name}")
|
||||
window.transient(self)
|
||||
window.columnconfigure(0, weight=1)
|
||||
window.rowconfigure(1, weight=1)
|
||||
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
preview = tk.Text(window, wrap="word", width=88, height=30)
|
||||
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
|
||||
preview.insert("1.0", message.get_content())
|
||||
preview.configure(state="disabled")
|
||||
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
|
||||
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
|
||||
preview.configure(yscrollcommand=scroll.set)
|
||||
ttk.Button(window, text="Schließen", command=window.destroy).grid(
|
||||
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
|
||||
)
|
||||
|
||||
def _generate(self) -> None:
|
||||
recipients = [member for member in self._selected_members() if member.email.strip()]
|
||||
if not recipients:
|
||||
messagebox.showinfo(
|
||||
"Keine Empfänger",
|
||||
"Bitte mindestens ein Mitglied mit E-Mail-Adresse auswählen.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||
if delivery_mode is None:
|
||||
return
|
||||
output_directory = None
|
||||
if delivery_mode == "local":
|
||||
selected = filedialog.askdirectory(
|
||||
parent=self,
|
||||
title="Ausgabeordner für die Thunderbird-Entwürfe auswählen",
|
||||
mustexist=True,
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
output_directory = selected
|
||||
# A mailing to the whole club cannot be taken back, so the count is confirmed
|
||||
# once more -- especially when "Senden" would put it on the wire right away.
|
||||
action = {
|
||||
"local": "als Datei abgelegt",
|
||||
"send": "sofort versandt",
|
||||
"drafts": "als Entwurf im Postfach abgelegt",
|
||||
}[delivery_mode]
|
||||
if not messagebox.askyesno(
|
||||
"Datenüberprüfung verschicken",
|
||||
f"An {len(recipients)} Mitglied(er) wird je eine Mail mit deren gespeicherten "
|
||||
f"Daten {action}.\n\nJetzt erzeugen?",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
self.configure(cursor="watch")
|
||||
self.update_idletasks()
|
||||
try:
|
||||
generated, warnings = generate_data_review_mails(
|
||||
self.repository,
|
||||
[member.member_id for member in recipients],
|
||||
delivery_mode=delivery_mode,
|
||||
output_directory=output_directory,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mails konnten nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
finally:
|
||||
self.configure(cursor="")
|
||||
location = {
|
||||
"local": f"in\n{output_directory}\nerzeugt",
|
||||
"send": "versandt",
|
||||
"drafts": "als Entwurf im Postfach abgelegt",
|
||||
}[delivery_mode]
|
||||
detail = (
|
||||
f"{len(generated)} Mail(s) wurden {location} und in den Mitgliederakten archiviert."
|
||||
)
|
||||
if warnings:
|
||||
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
|
||||
if len(warnings) > 12:
|
||||
detail += f"\n… und {len(warnings) - 12} weitere."
|
||||
messagebox.showinfo("Datenüberprüfung verschickt", detail, parent=self)
|
||||
self.destroy()
|
||||
if self.on_sent is not None:
|
||||
self.on_sent()
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from ccma.storage.repository import MemberRepository
|
||||
|
||||
|
||||
class EmailDeliveryChoiceDialog(tk.Toplevel):
|
||||
"""Shown once per generation action (not per individual e-mail) when the
|
||||
repository's delivery mode is "ask" -- lets the board decide per-batch whether
|
||||
to send immediately or park the e-mail(s) as a draft for later review."""
|
||||
|
||||
def __init__(self, master: tk.Misc, *, can_send: bool, can_draft: bool):
|
||||
super().__init__(master)
|
||||
self.result: str | None = None
|
||||
self.title("E-Mail-Versand")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
frame = ttk.Frame(self, padding=18)
|
||||
frame.pack(fill="both", expand=True)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text="Wie soll mit dieser E-Mail verfahren werden?",
|
||||
wraplength=360,
|
||||
justify="left",
|
||||
).pack(anchor="w", pady=(0, 14))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.pack(anchor="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self._cancel).pack(side="left", padx=(0, 8))
|
||||
send_button = ttk.Button(buttons, text="Senden", command=self._choose_send)
|
||||
send_button.pack(side="left", padx=(0, 8))
|
||||
send_button.configure(state="normal" if can_send else "disabled")
|
||||
draft_button = ttk.Button(
|
||||
buttons, text="Als Entwurf ablegen", style="Accent.TButton", command=self._choose_drafts
|
||||
)
|
||||
draft_button.pack(side="left")
|
||||
draft_button.configure(state="normal" if can_draft else "disabled")
|
||||
self.bind("<Escape>", lambda _event: self._cancel())
|
||||
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _choose_send(self) -> None:
|
||||
self.result = "send"
|
||||
self.destroy()
|
||||
|
||||
def _choose_drafts(self) -> None:
|
||||
self.result = "drafts"
|
||||
self.destroy()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
|
||||
def resolve_delivery_mode(master: tk.Misc, repository: MemberRepository) -> str | None:
|
||||
"""Returns the delivery mode to use for the next generated e-mail(s): "local",
|
||||
"send" or "drafts" -- or None if the board cancelled out of the "ask every time"
|
||||
prompt, in which case the caller should abort without generating anything."""
|
||||
settings = repository.get_email_settings()
|
||||
mode = settings["delivery_mode"]
|
||||
if mode in {"local", "send", "drafts"}:
|
||||
return mode
|
||||
dialog = EmailDeliveryChoiceDialog(
|
||||
master, can_send=bool(settings["smtp_host"]), can_draft=bool(settings["imap_host"])
|
||||
)
|
||||
master.wait_window(dialog)
|
||||
return dialog.result
|
||||
@@ -30,6 +30,19 @@ REMINDER_CHANNEL_LABELS = {
|
||||
"personal": "Persönlich",
|
||||
}
|
||||
|
||||
EMAIL_DELIVERY_MODE_LABELS = {
|
||||
"local": "Lokal speichern (bisheriges Verhalten)",
|
||||
"send": "Direkt versenden",
|
||||
"drafts": "Als Entwurf im Postfach ablegen",
|
||||
"ask": "Jedes Mal fragen",
|
||||
}
|
||||
|
||||
EMAIL_SECURITY_LABELS = {
|
||||
"starttls": "STARTTLS",
|
||||
"ssl": "SSL/TLS",
|
||||
"none": "Keine",
|
||||
}
|
||||
|
||||
|
||||
def display_label(labels: Mapping[str, str], key: str) -> str:
|
||||
return labels.get(key, key)
|
||||
|
||||
+101
-9
@@ -10,9 +10,11 @@ from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.asset_tab import AssetTab
|
||||
from ccma.ui.claim_tab import ClaimTab
|
||||
from ccma.ui.data_review_mail_dialog import DataReviewMailDialog
|
||||
from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog
|
||||
from ccma.ui.icons import IconStore
|
||||
from ccma.ui.member_tab import MemberTab
|
||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||
from ccma.ui.options_dialog import OptionsDialog
|
||||
from ccma.ui.sepa_dialog import SepaExportDialog
|
||||
from ccma.ui.theme import load_theme
|
||||
@@ -98,10 +100,14 @@ class MainWindow(ttk.Frame):
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
self._build_ribbon()
|
||||
# The read-only notice sits above the tabs rather than inside one, so it stays
|
||||
# visible no matter which member, asset or housekeeper tab is in front.
|
||||
self.messages = MessageBannerList(self)
|
||||
self.messages.grid(row=1, column=0, sticky="ew", pady=(10, 0))
|
||||
self.notebook = ttk.Notebook(self)
|
||||
self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0))
|
||||
self.notebook.grid(row=2, column=0, sticky="nsew", pady=(10, 0))
|
||||
self.tabs = TabManager(self.notebook)
|
||||
self.dashboard = DashboardTab(
|
||||
self.notebook,
|
||||
@@ -117,16 +123,75 @@ class MainWindow(ttk.Frame):
|
||||
icon_name="view-dashboard",
|
||||
)
|
||||
status = ttk.Frame(self, padding=(6, 5))
|
||||
status.grid(row=2, column=0, sticky="ew")
|
||||
status.grid(row=3, column=0, sticky="ew")
|
||||
status.columnconfigure(0, weight=1)
|
||||
ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
ttk.Label(
|
||||
status,
|
||||
text=f"STORE {self.repository.root} · VERSION {__version__}",
|
||||
style="Status.TLabel",
|
||||
).grid(row=0, column=1, sticky="e")
|
||||
self.store_var = tk.StringVar()
|
||||
ttk.Label(status, textvariable=self.store_var, style="Status.TLabel").grid(
|
||||
row=0, column=1, sticky="e"
|
||||
)
|
||||
# Everything that reflects write access is refreshed from one place, so a
|
||||
# store that becomes writable mid-session updates all of it together.
|
||||
self._refresh_store_state()
|
||||
|
||||
def _refresh_store_state(self) -> None:
|
||||
"""Window title, status bar and banner all say whether the store can be
|
||||
written to -- they are rebuilt together so none of them can be left behind."""
|
||||
read_only = self.repository.read_only
|
||||
title = f"CCMA · v{__version__}"
|
||||
self.master.title(f"{title} · NUR LESEN (Store schreibgeschützt)" if read_only else title)
|
||||
store_label = f"STORE {self.repository.root}"
|
||||
if read_only:
|
||||
store_label = f"{store_label} · NUR LESEN"
|
||||
self.store_var.set(f"{store_label} · VERSION {__version__}")
|
||||
messages = []
|
||||
if read_only:
|
||||
messages.append(
|
||||
TabMessage(
|
||||
"warning",
|
||||
"ACHTUNG: Der Mitglieder-Store ist schreibgeschützt eingebunden. "
|
||||
"Alle Daten sind nur lesbar – Änderungen, Dokumente, E-Mails und der "
|
||||
"Hausmeister sind deaktiviert. Den Store mit Schreibrechten einbinden "
|
||||
"und hier auf „Erneut prüfen“ klicken – ein Neustart ist dafür nicht "
|
||||
"nötig.",
|
||||
MessageAction("Erneut prüfen", self._recheck_store_access),
|
||||
)
|
||||
)
|
||||
self.messages.set_messages(messages)
|
||||
|
||||
def _recheck_store_access(self) -> None:
|
||||
"""Lets the board remount the volume without restarting first -- if it came
|
||||
back writable, every read-only marker goes away and the housekeeper, skipped
|
||||
at startup, can run right away."""
|
||||
if self.repository.refresh_read_only():
|
||||
self._refresh_store_state()
|
||||
self.status_var.set("Der Store ist weiterhin schreibgeschützt.")
|
||||
return
|
||||
self._refresh_store_state()
|
||||
self.status_var.set("Der Store ist wieder beschreibbar.")
|
||||
# The startup pass was skipped, so the task list is empty rather than current:
|
||||
# offer the run that fills it instead of leaving a misleading dashboard.
|
||||
if messagebox.askyesno(
|
||||
"Store beschreibbar",
|
||||
"Der Mitglieder-Store ist jetzt mit Schreibrechten eingebunden. Der "
|
||||
"Hausmeister wurde beim Start übersprungen, seine Vorgangsliste ist daher "
|
||||
"noch leer.\n\nJetzt einen Hausmeisterlauf starten?",
|
||||
parent=self,
|
||||
):
|
||||
self.run_housekeeper()
|
||||
|
||||
def _refuse_read_only(self, action: str) -> bool:
|
||||
if not self.repository.read_only:
|
||||
return False
|
||||
messagebox.showwarning(
|
||||
"Store ist schreibgeschützt",
|
||||
f"{action} ist nicht möglich, solange der Mitglieder-Store nur lesend "
|
||||
"eingebunden ist.",
|
||||
parent=self,
|
||||
)
|
||||
return True
|
||||
|
||||
def _build_ribbon(self) -> None:
|
||||
ribbon = ttk.Frame(self, style="Ribbon.TFrame")
|
||||
@@ -302,6 +367,7 @@ class MainWindow(ttk.Frame):
|
||||
on_open_assets=self.open_assets,
|
||||
on_open_asset=self.open_asset,
|
||||
on_return_asset=self.return_asset,
|
||||
on_run_housekeeper=self.run_housekeeper_for_member,
|
||||
)
|
||||
self.tabs.add(
|
||||
key,
|
||||
@@ -380,6 +446,7 @@ class MainWindow(ttk.Frame):
|
||||
self.findings,
|
||||
self.open_member,
|
||||
lambda: self.tabs.close(key),
|
||||
self.open_data_review_mail,
|
||||
)
|
||||
self.tabs.add(
|
||||
key,
|
||||
@@ -415,10 +482,19 @@ class MainWindow(ttk.Frame):
|
||||
def open_sepa_export(self) -> None:
|
||||
SepaExportDialog(self, self.repository)
|
||||
|
||||
def open_data_review_mail(self) -> None:
|
||||
if self._refuse_read_only("Der Versand der Datenüberprüfung"):
|
||||
return
|
||||
DataReviewMailDialog(self, self.repository)
|
||||
|
||||
def new_member(self) -> None:
|
||||
if self._refuse_read_only("Das Anlegen eines Mitglieds"):
|
||||
return
|
||||
NewMemberDialog(self, self.repository, self._member_created)
|
||||
|
||||
def new_asset(self) -> None:
|
||||
if self._refuse_read_only("Das Anlegen eines Assets"):
|
||||
return
|
||||
NewAssetDialog(self, self.repository, self._asset_created)
|
||||
|
||||
def _member_created(self, member: Member) -> None:
|
||||
@@ -498,19 +574,35 @@ class MainWindow(ttk.Frame):
|
||||
)
|
||||
|
||||
def run_housekeeper(self) -> list[HousekeeperFinding]:
|
||||
if self._refuse_read_only("Ein Hausmeisterlauf"):
|
||||
return self.findings
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
|
||||
return self.findings
|
||||
|
||||
def run_housekeeper_for_member(
|
||||
self, member_id: str, retroactive_claims: bool = False
|
||||
) -> list[HousekeeperFinding]:
|
||||
if self._refuse_read_only("Ein Hausmeisterlauf"):
|
||||
return self.findings
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
|
||||
member_id=member_id, retroactive_claims=retroactive_claims or None
|
||||
)
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
self.status_var.set(f"Hausmeisterlauf für Mitglied beendet: {len(self.findings)} Vorgänge insgesamt.")
|
||||
return self.findings
|
||||
|
||||
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
|
||||
if self._refuse_read_only("Das Löschen eines Hausmeister-Tasks"):
|
||||
return self.findings
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
self.status_var.set("Hausmeister-Task gelöscht.")
|
||||
return self.findings
|
||||
|
||||
def refresh_overview(self, *, run_housekeeper: bool = True) -> None:
|
||||
if run_housekeeper:
|
||||
if run_housekeeper and not self.repository.read_only:
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
|
||||
self.dashboard.update_data(self.repository.member_count(), self.findings)
|
||||
members_tab = self.tabs.tabs.get("members")
|
||||
|
||||
+105
-12
@@ -21,7 +21,7 @@ from ccma.domain.contributions import (
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
||||
@@ -38,6 +38,7 @@ from ccma.ui.labels import display_label, storage_key
|
||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
||||
from ccma.ui.scrolling import ScrollableFrame
|
||||
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog
|
||||
|
||||
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
||||
# and amount-like columns get the same fixed width, status columns get a third more
|
||||
@@ -67,6 +68,49 @@ def _claim_sort_value(data, claim: dict, column: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class _RunHousekeeperDialog(tk.Toplevel):
|
||||
"""Confirms a housekeeper run scoped to a single member, with the option to
|
||||
force retroactive claim creation for just this run without touching the
|
||||
application-wide default setting."""
|
||||
|
||||
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
|
||||
super().__init__(master)
|
||||
self.on_confirm = on_confirm
|
||||
self.title("Hausmeister für dieses Mitglied ausführen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
frame = ttk.Frame(self, padding=18)
|
||||
frame.pack(fill="both", expand=True)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text=(
|
||||
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
|
||||
"Andere Mitglieder bleiben unberührt."
|
||||
),
|
||||
wraplength=380,
|
||||
justify="left",
|
||||
).pack(anchor="w", pady=(0, 12))
|
||||
self.retroactive_var = tk.BooleanVar(value=False)
|
||||
ttk.Checkbutton(
|
||||
frame,
|
||||
text="Rückwirkende Beiträge/Forderungen erstellen",
|
||||
variable=self.retroactive_var,
|
||||
).pack(anchor="w", pady=(0, 14))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.pack(anchor="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
|
||||
side="left"
|
||||
)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _confirm(self) -> None:
|
||||
retroactive = self.retroactive_var.get()
|
||||
self.destroy()
|
||||
self.on_confirm(retroactive)
|
||||
|
||||
|
||||
class MemberTab(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -80,6 +124,7 @@ class MemberTab(ttk.Frame):
|
||||
on_open_assets: Callable[[], None],
|
||||
on_open_asset: Callable[[str], None],
|
||||
on_return_asset: Callable[[str], None],
|
||||
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.repository = repository
|
||||
@@ -91,6 +136,7 @@ class MemberTab(ttk.Frame):
|
||||
self.on_open_assets = on_open_assets
|
||||
self.on_open_asset = on_open_asset
|
||||
self.on_return_asset = on_return_asset
|
||||
self.on_run_housekeeper = on_run_housekeeper
|
||||
self.member = repository.get_member(member_id)
|
||||
self.variables: dict[str, tk.Variable] = {}
|
||||
self._field_sections: dict[str, str] = {}
|
||||
@@ -124,9 +170,14 @@ class MemberTab(ttk.Frame):
|
||||
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
|
||||
row=2, column=0, sticky="w", pady=(3, 0)
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self._close).grid(
|
||||
row=0, column=1, sticky="ne", padx=(12, 0)
|
||||
)
|
||||
header_actions = ttk.Frame(header)
|
||||
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
|
||||
ttk.Button(
|
||||
header_actions,
|
||||
text="Hausmeister für dieses Mitglied",
|
||||
command=self._run_housekeeper_for_member,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
|
||||
self.messages = MessageBannerList(self)
|
||||
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||
self.messages.grid_remove()
|
||||
@@ -305,6 +356,13 @@ class MemberTab(ttk.Frame):
|
||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
||||
claim_actions = ttk.Frame(contribution_tab)
|
||||
claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||
ttk.Button(
|
||||
claim_actions,
|
||||
text="Willkommens- und Erstrechnungsmail",
|
||||
command=self._send_welcome_mail,
|
||||
).pack(side="left")
|
||||
|
||||
contribution_override_tab.columnconfigure(0, weight=1)
|
||||
contribution_override_tab.rowconfigure(1, weight=1)
|
||||
@@ -563,6 +621,25 @@ class MemberTab(ttk.Frame):
|
||||
return
|
||||
self.on_close()
|
||||
|
||||
def _run_housekeeper_for_member(self) -> None:
|
||||
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
|
||||
|
||||
def _execute_housekeeper_run(self, retroactive: bool) -> None:
|
||||
try:
|
||||
findings = self.on_run_housekeeper(self.member_id, retroactive)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
member_findings = [item for item in findings if item.member_id == self.member_id]
|
||||
messagebox.showinfo(
|
||||
"Hausmeister ausgeführt",
|
||||
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
|
||||
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
|
||||
parent=self,
|
||||
)
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _build_timeline(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(1, weight=1)
|
||||
@@ -1053,7 +1130,11 @@ class MemberTab(ttk.Frame):
|
||||
self._confirm_integrity_and_then(self._refresh_hashes_only)
|
||||
|
||||
def _refresh_hashes_only(self) -> None:
|
||||
self.repository.refresh_member_record_hashes(self.member_id)
|
||||
try:
|
||||
self.repository.refresh_member_record_hashes(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
|
||||
return
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
@@ -1073,13 +1154,17 @@ class MemberTab(ttk.Frame):
|
||||
text = self.comment_var.get().strip()
|
||||
if not text:
|
||||
return
|
||||
self.repository.append_event(
|
||||
self.member_id,
|
||||
event_type="board_comment",
|
||||
summary=text,
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
)
|
||||
try:
|
||||
self.repository.append_event(
|
||||
self.member_id,
|
||||
event_type="board_comment",
|
||||
summary=text,
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Kommentar konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.comment_var.set("")
|
||||
self._refresh_events()
|
||||
|
||||
@@ -1108,6 +1193,14 @@ class MemberTab(ttk.Frame):
|
||||
except OSError as exc:
|
||||
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
|
||||
|
||||
def _send_welcome_mail(self) -> None:
|
||||
WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)
|
||||
|
||||
def _welcome_mail_sent(self) -> None:
|
||||
self._refresh_documents()
|
||||
self._refresh_events()
|
||||
self.on_changed()
|
||||
|
||||
def _create_document(self) -> None:
|
||||
DocumentTemplateDialog(
|
||||
self,
|
||||
|
||||
+694
-12
@@ -6,15 +6,31 @@ from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.contributions import decimal_value, money_text
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplateError,
|
||||
default_mail_template,
|
||||
placeholder_entries,
|
||||
template_spec,
|
||||
validate_mail_template,
|
||||
)
|
||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
normalize_anniversary_intervals,
|
||||
)
|
||||
from ccma.services.mail_delivery import list_imap_folders, test_imap_connection, test_smtp_connection
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
|
||||
from ccma.ui.changelog_view import ChangelogView
|
||||
from ccma.ui.icons import IconStore
|
||||
from ccma.ui.labels import THEME_LABELS, display_label, storage_key
|
||||
from ccma.ui.labels import (
|
||||
EMAIL_DELIVERY_MODE_LABELS,
|
||||
EMAIL_SECURITY_LABELS,
|
||||
THEME_LABELS,
|
||||
display_label,
|
||||
storage_key,
|
||||
)
|
||||
|
||||
|
||||
class OptionsDialog(tk.Toplevel):
|
||||
@@ -59,14 +75,34 @@ class OptionsDialog(tk.Toplevel):
|
||||
"website", "iban", "bic", "creditor_id",
|
||||
)
|
||||
}
|
||||
# Mail templates are edited one at a time but saved together with the rest of
|
||||
# the options, so the edits of every tab are kept in memory until "Speichern".
|
||||
self.mail_template_key = MAIL_TEMPLATES[0].key
|
||||
self.mail_template_edits: dict[str, tuple[str, str]] = {}
|
||||
self.mail_template_original: dict[str, tuple[str, str]] = {}
|
||||
self.mail_template_var = tk.StringVar(value=MAIL_TEMPLATES[0].label)
|
||||
self.mail_template_subject_var = tk.StringVar()
|
||||
self.mail_template_hint_var = tk.StringVar()
|
||||
self.mail_template_snippets: dict[str, str] = {}
|
||||
self.title("Optionen")
|
||||
self.geometry("900x650")
|
||||
self.minsize(760, 540)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
# Tk refuses a grab on a window that is not on screen yet ("window not
|
||||
# viewable"), and this dialog builds enough tabs to still be unmapped here --
|
||||
# so the grab waits for the window like it does in the other dialogs.
|
||||
self.after_idle(self.grab_set)
|
||||
self.resizable(True, True)
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
# A ttk.Notebook sizes itself to its tallest/widest tab, so once every tab
|
||||
# (including "Mahnungen" with its two tables) is built, the natural required
|
||||
# size reflects whichever one actually needs the most room -- open at that size
|
||||
# instead of a static guess that clips a table/buttons at the bottom. The floor
|
||||
# keeps the window from opening awkwardly small on short content.
|
||||
self.update_idletasks()
|
||||
width = max(900, self.winfo_reqwidth())
|
||||
height = max(650, self.winfo_reqheight())
|
||||
self.geometry(f"{width}x{height}")
|
||||
self.minsize(760, 540)
|
||||
self.after_idle(self._center_on_parent)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -84,18 +120,27 @@ class OptionsDialog(tk.Toplevel):
|
||||
member_numbers = ttk.Frame(notebook, padding=16)
|
||||
organization = ttk.Frame(notebook, padding=16)
|
||||
automation = ttk.Frame(notebook, padding=16)
|
||||
reminders = ttk.Frame(notebook, padding=16)
|
||||
email = ttk.Frame(notebook, padding=16)
|
||||
mail_templates = ttk.Frame(notebook, padding=16)
|
||||
changelog = ChangelogView(notebook)
|
||||
notebook.add(paths, text="Pfade")
|
||||
notebook.add(appearance, text="Darstellung")
|
||||
notebook.add(member_numbers, text="Mitgliedsnummern")
|
||||
notebook.add(organization, text="Verein / Absender")
|
||||
notebook.add(automation, text="Hausmeister")
|
||||
notebook.add(reminders, text="Mahnungen")
|
||||
notebook.add(email, text="E-Mail-Versand")
|
||||
notebook.add(mail_templates, text="E-Mail-Vorlagen")
|
||||
notebook.add(changelog, text="Changelog")
|
||||
self._build_paths(paths)
|
||||
self._build_appearance(appearance)
|
||||
self._build_member_numbers(member_numbers)
|
||||
self._build_organization(organization)
|
||||
self._build_automation(automation)
|
||||
self._build_reminders(reminders)
|
||||
self._build_email(email)
|
||||
self._build_mail_templates(mail_templates)
|
||||
|
||||
buttons = ttk.Frame(root)
|
||||
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
||||
@@ -336,6 +381,436 @@ class OptionsDialog(tk.Toplevel):
|
||||
preview = f"UNGÜLTIG: {exc}"
|
||||
self.number_preview_var.set(preview)
|
||||
|
||||
def _build_reminders(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
policy = self.repository.get_reminder_policy()
|
||||
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
|
||||
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
|
||||
row=0, column=0, sticky="w", pady=6
|
||||
)
|
||||
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
|
||||
row=0, column=1, sticky="w", pady=6
|
||||
)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
|
||||
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
|
||||
levels_frame.columnconfigure(0, weight=1)
|
||||
self.levels = [dict(level) for level in policy["levels"]]
|
||||
self.levels_tree = ttk.Treeview(
|
||||
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
|
||||
)
|
||||
for key, title, width in (
|
||||
("level", "Stufe", 55),
|
||||
("name", "Name", 220),
|
||||
("fee", "Gebühr", 90),
|
||||
("deadline", "Frist (Tage)", 100),
|
||||
):
|
||||
self.levels_tree.heading(key, text=title)
|
||||
self.levels_tree.column(key, width=width, anchor="w")
|
||||
self.levels_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
|
||||
|
||||
level_controls = ttk.Frame(levels_frame)
|
||||
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.level_name_var = tk.StringVar()
|
||||
self.level_fee_var = tk.StringVar()
|
||||
self.level_deadline_var = tk.StringVar()
|
||||
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
|
||||
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
|
||||
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
|
||||
items_frame.columnconfigure(0, weight=1)
|
||||
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
|
||||
self.standard_items_tree = ttk.Treeview(
|
||||
items_frame, columns=("description", "amount"), show="headings", height=5
|
||||
)
|
||||
self.standard_items_tree.heading("description", text="Beschreibung")
|
||||
self.standard_items_tree.heading("amount", text="Standardbetrag")
|
||||
self.standard_items_tree.column("description", width=260, anchor="w")
|
||||
self.standard_items_tree.column("amount", width=120, anchor="w")
|
||||
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.standard_items_tree.bind(
|
||||
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
|
||||
)
|
||||
|
||||
item_controls = ttk.Frame(items_frame)
|
||||
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.standard_item_description_var = tk.StringVar()
|
||||
self.standard_item_amount_var = tk.StringVar()
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
|
||||
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=560,
|
||||
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
self._refresh_levels()
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _refresh_levels(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
self.levels_tree.delete(*self.levels_tree.get_children())
|
||||
for index, level in enumerate(self.levels):
|
||||
self.levels_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(
|
||||
index + 1,
|
||||
level.get("name", ""),
|
||||
f"{level.get('fee', '0.00')} EUR",
|
||||
level.get("payment_deadline_days", 14),
|
||||
),
|
||||
)
|
||||
if selected and self.levels_tree.exists(selected[0]):
|
||||
self.levels_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
level = self.levels[int(selected[0])]
|
||||
self.level_name_var.set(level.get("name", ""))
|
||||
self.level_fee_var.set(str(level.get("fee", "0.00")))
|
||||
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
|
||||
|
||||
def _apply_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
name = self.level_name_var.get().strip()
|
||||
if not name:
|
||||
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get())
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels[int(selected[0])] = {
|
||||
"name": name,
|
||||
"fee": money_text(fee),
|
||||
"payment_deadline_days": deadline,
|
||||
}
|
||||
self._refresh_levels()
|
||||
|
||||
def _add_level(self) -> None:
|
||||
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get() or "14")
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
|
||||
self._refresh_levels()
|
||||
|
||||
def _remove_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
del self.levels[int(selected[0])]
|
||||
self._refresh_levels()
|
||||
|
||||
def _refresh_standard_items(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
|
||||
for index, item in enumerate(self.standard_items):
|
||||
self.standard_items_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
|
||||
)
|
||||
if selected and self.standard_items_tree.exists(selected[0]):
|
||||
self.standard_items_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
item = self.standard_items[int(selected[0])]
|
||||
self.standard_item_description_var.set(item.get("description", ""))
|
||||
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
|
||||
|
||||
def _apply_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
description = self.standard_item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items[int(selected[0])] = {
|
||||
"description": description,
|
||||
"default_amount": money_text(amount),
|
||||
}
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _add_standard_item(self) -> None:
|
||||
description = self.standard_item_description_var.get().strip() or "Neue Position"
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _remove_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.standard_items[int(selected[0])]
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _build_email(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
email_settings = self.repository.get_email_settings()
|
||||
|
||||
self.email_delivery_mode_var = tk.StringVar(
|
||||
value=display_label(EMAIL_DELIVERY_MODE_LABELS, email_settings["delivery_mode"])
|
||||
)
|
||||
ttk.Label(parent, text="Versandmodus").grid(row=0, column=0, sticky="w", pady=6)
|
||||
ttk.Combobox(
|
||||
parent,
|
||||
textvariable=self.email_delivery_mode_var,
|
||||
values=list(EMAIL_DELIVERY_MODE_LABELS.values()),
|
||||
state="readonly",
|
||||
width=42,
|
||||
).grid(row=0, column=1, sticky="w", pady=6)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Gilt für Mahnungs- und SEPA-Info-Mails. „Jedes Mal fragen“ zeigt vor jeder "
|
||||
"Erzeugung eine Auswahl zwischen Senden und Entwurf ablegen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=680,
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
smtp_frame = ttk.LabelFrame(parent, text="SMTP (zum direkten Versenden)", padding=10)
|
||||
smtp_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(4, 0))
|
||||
smtp_frame.columnconfigure(1, weight=1)
|
||||
self.smtp_host_var = tk.StringVar(value=email_settings["smtp_host"])
|
||||
self.smtp_port_var = tk.StringVar(value=str(email_settings["smtp_port"]))
|
||||
self.smtp_security_var = tk.StringVar(
|
||||
value=display_label(EMAIL_SECURITY_LABELS, email_settings["smtp_security"])
|
||||
)
|
||||
self.smtp_username_var = tk.StringVar(value=email_settings["smtp_username"])
|
||||
self.smtp_password_var = tk.StringVar(value=email_settings["smtp_password"])
|
||||
ttk.Label(smtp_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_host_var).grid(
|
||||
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Label(smtp_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_port_var, width=8).grid(row=0, column=3, pady=4)
|
||||
ttk.Label(smtp_frame, text="Verschlüsselung").grid(
|
||||
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Combobox(
|
||||
smtp_frame,
|
||||
textvariable=self.smtp_security_var,
|
||||
values=list(EMAIL_SECURITY_LABELS.values()),
|
||||
state="readonly",
|
||||
width=14,
|
||||
).grid(row=1, column=1, sticky="w", pady=4)
|
||||
ttk.Label(smtp_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_username_var).grid(
|
||||
row=2, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Label(smtp_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_password_var, show="•").grid(
|
||||
row=3, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Button(smtp_frame, text="Verbindung testen", command=self._test_smtp_connection).grid(
|
||||
row=4, column=0, columnspan=4, sticky="w", pady=(8, 0)
|
||||
)
|
||||
|
||||
imap_frame = ttk.LabelFrame(parent, text="IMAP (für Entwürfe & Gesendet)", padding=10)
|
||||
imap_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
||||
imap_frame.columnconfigure(1, weight=1)
|
||||
self.imap_host_var = tk.StringVar(value=email_settings["imap_host"])
|
||||
self.imap_port_var = tk.StringVar(value=str(email_settings["imap_port"]))
|
||||
self.imap_security_var = tk.StringVar(
|
||||
value=display_label(EMAIL_SECURITY_LABELS, email_settings["imap_security"])
|
||||
)
|
||||
self.imap_username_var = tk.StringVar(value=email_settings["imap_username"])
|
||||
self.imap_password_var = tk.StringVar(value=email_settings["imap_password"])
|
||||
self.imap_drafts_folder_var = tk.StringVar(value=email_settings["imap_drafts_folder"])
|
||||
self.imap_sent_enabled_var = tk.BooleanVar(value=email_settings["imap_sent_enabled"])
|
||||
self.imap_sent_folder_var = tk.StringVar(value=email_settings["imap_sent_folder"])
|
||||
ttk.Label(imap_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_host_var).grid(
|
||||
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Label(imap_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_port_var, width=8).grid(row=0, column=3, pady=4)
|
||||
ttk.Label(imap_frame, text="Verschlüsselung").grid(
|
||||
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Combobox(
|
||||
imap_frame,
|
||||
textvariable=self.imap_security_var,
|
||||
values=list(EMAIL_SECURITY_LABELS.values()),
|
||||
state="readonly",
|
||||
width=14,
|
||||
).grid(row=1, column=1, sticky="w", pady=4)
|
||||
ttk.Label(imap_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_username_var).grid(
|
||||
row=2, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Label(imap_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_password_var, show="•").grid(
|
||||
row=3, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Label(imap_frame, text="Entwürfe-Ordner").grid(row=4, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
self.imap_drafts_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_drafts_folder_var)
|
||||
self.imap_drafts_folder_combo.grid(row=4, column=1, columnspan=3, sticky="ew", pady=4)
|
||||
ttk.Checkbutton(
|
||||
imap_frame,
|
||||
text="Direkt versandte Mahnungen/Info-Mails zusätzlich in einen Gesendet-Ordner ablegen",
|
||||
variable=self.imap_sent_enabled_var,
|
||||
).grid(row=5, column=0, columnspan=4, sticky="w", pady=(8, 4))
|
||||
ttk.Label(imap_frame, text="Gesendet-Ordner").grid(row=6, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
self.imap_sent_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_sent_folder_var)
|
||||
self.imap_sent_folder_combo.grid(row=6, column=1, columnspan=3, sticky="ew", pady=4)
|
||||
buttons_row = ttk.Frame(imap_frame)
|
||||
buttons_row.grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0))
|
||||
ttk.Button(buttons_row, text="Verbindung testen", command=self._test_imap_connection).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons_row, text="Ordnerliste laden", command=self._load_imap_folders).pack(side="left")
|
||||
ttk.Label(
|
||||
imap_frame,
|
||||
text=(
|
||||
"„Ordnerliste laden“ ruft die Ordner live vom Server ab (Zugangsdaten oben müssen "
|
||||
"dafür bereits ausgefüllt sein) und füllt beide Dropdowns; freie Eingabe bleibt "
|
||||
"möglich -- ein dabei angegebener, noch nicht vorhandener Ordner wird bei der "
|
||||
"nächsten Ablage automatisch angelegt."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=660,
|
||||
).grid(row=8, column=0, columnspan=4, sticky="w", pady=(6, 0))
|
||||
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Server, Zugangsdaten und Passwörter werden mit den übrigen Vereinseinstellungen "
|
||||
"in repository.json innerhalb dieses Mitglieder-Stores gespeichert -- pro Store "
|
||||
"lässt sich so ein eigenes Postfach hinterlegen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=680,
|
||||
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(12, 0))
|
||||
|
||||
def _collect_email_settings(self) -> dict:
|
||||
return {
|
||||
"delivery_mode": storage_key(EMAIL_DELIVERY_MODE_LABELS, self.email_delivery_mode_var.get()),
|
||||
"smtp_host": self.smtp_host_var.get(),
|
||||
"smtp_port": self.smtp_port_var.get(),
|
||||
"smtp_security": storage_key(EMAIL_SECURITY_LABELS, self.smtp_security_var.get()),
|
||||
"smtp_username": self.smtp_username_var.get(),
|
||||
"smtp_password": self.smtp_password_var.get(),
|
||||
"imap_host": self.imap_host_var.get(),
|
||||
"imap_port": self.imap_port_var.get(),
|
||||
"imap_security": storage_key(EMAIL_SECURITY_LABELS, self.imap_security_var.get()),
|
||||
"imap_username": self.imap_username_var.get(),
|
||||
"imap_password": self.imap_password_var.get(),
|
||||
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
|
||||
"imap_sent_enabled": self.imap_sent_enabled_var.get(),
|
||||
"imap_sent_folder": self.imap_sent_folder_var.get(),
|
||||
}
|
||||
|
||||
def _test_smtp_connection(self) -> None:
|
||||
settings = self._collect_email_settings()
|
||||
try:
|
||||
settings["smtp_port"] = int(settings["smtp_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror("Ungültiger Port", "Der SMTP-Port muss eine ganze Zahl sein.", parent=self)
|
||||
return
|
||||
try:
|
||||
test_smtp_connection(settings)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("SMTP-Verbindung fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo("SMTP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
|
||||
|
||||
def _test_imap_connection(self) -> None:
|
||||
settings = self._collect_email_settings()
|
||||
try:
|
||||
settings["imap_port"] = int(settings["imap_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
|
||||
return
|
||||
try:
|
||||
test_imap_connection(settings)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("IMAP-Verbindung fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
|
||||
|
||||
def _load_imap_folders(self) -> None:
|
||||
settings = self._collect_email_settings()
|
||||
try:
|
||||
settings["imap_port"] = int(settings["imap_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
|
||||
return
|
||||
try:
|
||||
folders = list_imap_folders(settings)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Ordnerliste konnte nicht geladen werden", str(exc), parent=self)
|
||||
return
|
||||
self.imap_drafts_folder_combo.configure(values=folders)
|
||||
self.imap_sent_folder_combo.configure(values=folders)
|
||||
messagebox.showinfo("Ordnerliste geladen", f"{len(folders)} Ordner gefunden.", parent=self)
|
||||
|
||||
def _choose_store(self) -> None:
|
||||
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
|
||||
initial = current if current.is_dir() else current.parent
|
||||
@@ -377,9 +852,19 @@ class OptionsDialog(tk.Toplevel):
|
||||
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
|
||||
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
|
||||
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
|
||||
grace_days_after_due = _parse_day_window(self.grace_days_var.get(), "Kulanzfrist")
|
||||
except (ValueError, IntervalValidationError) as exc:
|
||||
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
|
||||
return
|
||||
email_settings = self._collect_email_settings()
|
||||
try:
|
||||
email_settings["smtp_port"] = int(email_settings["smtp_port"])
|
||||
email_settings["imap_port"] = int(email_settings["imap_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Ungültige E-Mail-Einstellung", "SMTP-/IMAP-Port müssen ganze Zahlen sein.", parent=self
|
||||
)
|
||||
return
|
||||
store_text = self.store_var.get().strip()
|
||||
if not store_text:
|
||||
messagebox.showerror(
|
||||
@@ -429,23 +914,220 @@ class OptionsDialog(tk.Toplevel):
|
||||
self.config_obj.optional_member_fields = tuple(
|
||||
field for field, variable in self.optional_member_field_vars.items() if variable.get()
|
||||
)
|
||||
# Program settings live in the user's config directory and stay writable even
|
||||
# when the member store does not -- so they are saved either way, and only the
|
||||
# store-backed settings are skipped (with a notice) on a read-only store.
|
||||
store_read_only = self.repository.read_only
|
||||
try:
|
||||
self.config_obj.save()
|
||||
self.repository.save_member_number_policy(
|
||||
mode=number_mode,
|
||||
pattern=number_pattern,
|
||||
allocation_strategy=self.number_strategy_var.get(),
|
||||
)
|
||||
self.repository.save_organization(
|
||||
{key: variable.get() for key, variable in self.organization_vars.items()}
|
||||
)
|
||||
if not store_read_only:
|
||||
self.repository.save_member_number_policy(
|
||||
mode=number_mode,
|
||||
pattern=number_pattern,
|
||||
allocation_strategy=self.number_strategy_var.get(),
|
||||
)
|
||||
self.repository.save_organization(
|
||||
{key: variable.get() for key, variable in self.organization_vars.items()}
|
||||
)
|
||||
self.repository.save_reminder_policy(
|
||||
grace_days_after_due=grace_days_after_due,
|
||||
levels=self.levels,
|
||||
standard_fee_items=self.standard_items,
|
||||
)
|
||||
self.repository.save_email_settings(**email_settings)
|
||||
self._save_mail_templates()
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
if store_read_only:
|
||||
messagebox.showwarning(
|
||||
"Store ist schreibgeschützt",
|
||||
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
|
||||
"Mitgliedsnummern, Mahnungen, E-Mail-Versand und E-Mail-Vorlagen konnten "
|
||||
"nicht gespeichert werden, weil der Mitglieder-Store nur lesend "
|
||||
"eingebunden ist.",
|
||||
parent=self,
|
||||
)
|
||||
if self.on_saved:
|
||||
self.on_saved(store_changed)
|
||||
self.destroy()
|
||||
|
||||
def _build_mail_templates(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(4, weight=1)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Die Texte aller vom Programm erzeugten E-Mails liegen als bearbeitbare "
|
||||
"Vorlagen im Mitglieder-Store unter templates/mail/. Platzhalter in doppelten "
|
||||
"geschweiften Klammern werden beim Versand mit den Daten des Mitglieds gefüllt."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=820,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
chooser = ttk.Frame(parent)
|
||||
chooser.grid(row=1, column=0, columnspan=2, sticky="ew")
|
||||
chooser.columnconfigure(1, weight=1)
|
||||
ttk.Label(chooser, text="Vorlage").grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||
selector = ttk.Combobox(
|
||||
chooser,
|
||||
textvariable=self.mail_template_var,
|
||||
values=[spec.label for spec in MAIL_TEMPLATES],
|
||||
state="readonly",
|
||||
width=32,
|
||||
)
|
||||
selector.grid(row=0, column=1, sticky="w")
|
||||
selector.bind("<<ComboboxSelected>>", lambda _event: self._select_mail_template())
|
||||
ttk.Button(
|
||||
chooser, text="Standardtext wiederherstellen", command=self._reset_mail_template
|
||||
).grid(row=0, column=2, sticky="e", padx=(12, 0))
|
||||
ttk.Label(
|
||||
parent, textvariable=self.mail_template_hint_var, style="Muted.TLabel", wraplength=820
|
||||
).grid(row=2, column=0, columnspan=2, sticky="w", pady=(6, 12))
|
||||
|
||||
subject = ttk.Frame(parent)
|
||||
subject.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||
subject.columnconfigure(1, weight=1)
|
||||
ttk.Label(subject, text="Betreff").grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||
ttk.Entry(subject, textvariable=self.mail_template_subject_var).grid(
|
||||
row=0, column=1, sticky="ew"
|
||||
)
|
||||
|
||||
editor = ttk.Frame(parent)
|
||||
editor.grid(row=4, column=0, sticky="nsew")
|
||||
editor.columnconfigure(0, weight=1)
|
||||
editor.rowconfigure(0, weight=1)
|
||||
self.mail_template_body = tk.Text(editor, wrap="word", height=16, width=70, undo=True)
|
||||
self.mail_template_body.grid(row=0, column=0, sticky="nsew")
|
||||
body_scroll = ttk.Scrollbar(editor, orient="vertical", command=self.mail_template_body.yview)
|
||||
body_scroll.grid(row=0, column=1, sticky="ns")
|
||||
self.mail_template_body.configure(yscrollcommand=body_scroll.set)
|
||||
|
||||
placeholders = ttk.Frame(parent)
|
||||
placeholders.grid(row=4, column=1, sticky="nsew", padx=(12, 0))
|
||||
placeholders.columnconfigure(0, weight=1)
|
||||
placeholders.rowconfigure(1, weight=1)
|
||||
ttk.Label(placeholders, text="Platzhalter (Doppelklick fügt ein)").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", pady=(0, 4)
|
||||
)
|
||||
self.mail_template_placeholders = ttk.Treeview(
|
||||
placeholders, columns=("name", "help"), show="headings", selectmode="browse", height=14
|
||||
)
|
||||
for key, title, width in (("name", "Platzhalter", 210), ("help", "Bedeutung", 230)):
|
||||
self.mail_template_placeholders.heading(key, text=title)
|
||||
self.mail_template_placeholders.column(key, width=width, anchor="w", stretch=key == "help")
|
||||
self.mail_template_placeholders.grid(row=1, column=0, sticky="nsew")
|
||||
placeholder_scroll = ttk.Scrollbar(
|
||||
placeholders, orient="vertical", command=self.mail_template_placeholders.yview
|
||||
)
|
||||
placeholder_scroll.grid(row=1, column=1, sticky="ns")
|
||||
self.mail_template_placeholders.configure(yscrollcommand=placeholder_scroll.set)
|
||||
self.mail_template_placeholders.bind(
|
||||
"<Double-1>", lambda _event: self._insert_mail_placeholder()
|
||||
)
|
||||
self.mail_template_placeholders.bind(
|
||||
"<Return>", lambda _event: self._insert_mail_placeholder()
|
||||
)
|
||||
self._load_mail_template(self.mail_template_key)
|
||||
|
||||
def _load_mail_template(self, key: str) -> None:
|
||||
if key not in self.mail_template_edits:
|
||||
try:
|
||||
template = self.repository.get_mail_template(key)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror(
|
||||
"Vorlage konnte nicht geladen werden", str(exc), parent=self
|
||||
)
|
||||
return
|
||||
self.mail_template_edits[key] = (template.subject, template.body)
|
||||
self.mail_template_original[key] = (template.subject, template.body)
|
||||
subject, body = self.mail_template_edits[key]
|
||||
self.mail_template_key = key
|
||||
spec = template_spec(key)
|
||||
self.mail_template_var.set(spec.label)
|
||||
self.mail_template_hint_var.set(f"{spec.description} (Datei: {spec.filename})")
|
||||
self.mail_template_subject_var.set(subject)
|
||||
self.mail_template_body.delete("1.0", "end")
|
||||
self.mail_template_body.insert("1.0", body)
|
||||
self.mail_template_placeholders.delete(*self.mail_template_placeholders.get_children())
|
||||
# The row text is a readable label; what actually gets inserted is the entry's
|
||||
# snippet, which for a repeat block is the whole block and not its description.
|
||||
self.mail_template_snippets = {}
|
||||
for entry in placeholder_entries(key):
|
||||
label = f" {entry.label}" if entry.indented else entry.label
|
||||
row = self.mail_template_placeholders.insert(
|
||||
"", "end", values=(label, entry.description)
|
||||
)
|
||||
self.mail_template_snippets[row] = entry.snippet
|
||||
|
||||
def _capture_mail_template(self) -> None:
|
||||
if not hasattr(self, "mail_template_body"):
|
||||
return
|
||||
self.mail_template_edits[self.mail_template_key] = (
|
||||
self.mail_template_subject_var.get(),
|
||||
self.mail_template_body.get("1.0", "end-1c"),
|
||||
)
|
||||
|
||||
def _select_mail_template(self) -> None:
|
||||
self._capture_mail_template()
|
||||
label = self.mail_template_var.get()
|
||||
key = next((spec.key for spec in MAIL_TEMPLATES if spec.label == label), None)
|
||||
if key:
|
||||
self._load_mail_template(key)
|
||||
|
||||
def _reset_mail_template(self) -> None:
|
||||
key = self.mail_template_key
|
||||
spec = template_spec(key)
|
||||
if not messagebox.askyesno(
|
||||
"Standardtext wiederherstellen",
|
||||
f"Soll die Vorlage „{spec.label}“ auf den mitgelieferten Standardtext "
|
||||
"zurückgesetzt werden? Deine Änderungen an dieser Vorlage gehen dabei verloren.",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
template = default_mail_template(key)
|
||||
except MailTemplateError as exc:
|
||||
messagebox.showerror("Standardtext nicht verfügbar", str(exc), parent=self)
|
||||
return
|
||||
self.mail_template_edits[key] = (template.subject, template.body)
|
||||
self._load_mail_template(key)
|
||||
|
||||
def _insert_mail_placeholder(self) -> None:
|
||||
selected = self.mail_template_placeholders.selection()
|
||||
if not selected:
|
||||
return
|
||||
snippet = self.mail_template_snippets.get(selected[0])
|
||||
if not snippet:
|
||||
return
|
||||
# A multi-line block only works on lines of its own, so it starts on a fresh
|
||||
# one instead of being appended to whatever the cursor sat behind.
|
||||
if "\n" in snippet.strip() and not self._mail_cursor_at_line_start():
|
||||
snippet = "\n" + snippet
|
||||
self.mail_template_body.insert("insert", snippet)
|
||||
self.mail_template_body.focus_set()
|
||||
|
||||
def _mail_cursor_at_line_start(self) -> bool:
|
||||
return str(self.mail_template_body.index("insert")).split(".")[1] == "0"
|
||||
|
||||
def _save_mail_templates(self) -> None:
|
||||
self._capture_mail_template()
|
||||
pending = {
|
||||
key: value
|
||||
for key, value in self.mail_template_edits.items()
|
||||
if self.mail_template_original.get(key) != value
|
||||
}
|
||||
# Check every edited template before writing the first one: a mistake in one
|
||||
# must not leave the others half-saved.
|
||||
for key, (subject, body) in pending.items():
|
||||
try:
|
||||
validate_mail_template(key, subject, body)
|
||||
except MailTemplateError as exc:
|
||||
raise RepositoryError(f"{template_spec(key).label}: {exc}") from exc
|
||||
for key, (subject, body) in pending.items():
|
||||
self.repository.save_mail_template(key, subject=subject, body=body)
|
||||
|
||||
def _center_on_parent(self) -> None:
|
||||
self.update_idletasks()
|
||||
parent = self.master.winfo_toplevel()
|
||||
|
||||
+86
-26
@@ -10,6 +10,7 @@ from ccma.domain.dates import DateValidationError, normalize_date_input
|
||||
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
|
||||
from ccma.services.sepa_mail import generate_debit_mails
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||
|
||||
|
||||
def _next_weekday(selected: date) -> date:
|
||||
@@ -40,7 +41,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
self.rowconfigure(3, weight=1)
|
||||
filters = ttk.Frame(self, padding=12)
|
||||
filters.grid(row=0, column=0, sticky="ew")
|
||||
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
|
||||
@@ -57,10 +58,23 @@ class SepaExportDialog(tk.Toplevel):
|
||||
)
|
||||
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
|
||||
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||
row=1, column=0, sticky="w", padx=12, pady=(0, 8)
|
||||
row=1, column=0, sticky="w", padx=12, pady=(0, 2)
|
||||
)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick "
|
||||
"abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei "
|
||||
"einem späteren Einzug zu berücksichtigen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=960,
|
||||
).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
|
||||
self.table = ttk.Treeview(
|
||||
self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings"
|
||||
self,
|
||||
columns=("number", "name", "iban", "mandate", "purpose", "amount"),
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
)
|
||||
for key, label, width, anchor in (
|
||||
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
|
||||
@@ -69,9 +83,10 @@ class SepaExportDialog(tk.Toplevel):
|
||||
):
|
||||
self.table.heading(key, text=label)
|
||||
self.table.column(key, width=width, anchor=anchor)
|
||||
self.table.grid(row=2, column=0, sticky="nsew", padx=12)
|
||||
self.table.grid(row=3, column=0, sticky="nsew", padx=12)
|
||||
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
footer = ttk.Frame(self, padding=12)
|
||||
footer.grid(row=3, column=0, sticky="ew")
|
||||
footer.grid(row=4, column=0, sticky="ew")
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
|
||||
@@ -107,6 +122,14 @@ class SepaExportDialog(tk.Toplevel):
|
||||
return due_from, due, collection
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Preserve which members were deliberately deselected across a refresh (e.g.
|
||||
# _prepare() re-querying right before an export) by identity, not by row index
|
||||
# -- a debit newly appearing in the requeried list still defaults to selected.
|
||||
previously_deselected = {
|
||||
self.debits[int(iid)].member_id
|
||||
for iid in self.table.get_children()
|
||||
if iid not in self.table.selection()
|
||||
}
|
||||
try:
|
||||
due_from, due, _collection = self._dates()
|
||||
self.debits, self.warnings = pending_direct_debits(
|
||||
@@ -116,16 +139,39 @@ class SepaExportDialog(tk.Toplevel):
|
||||
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
|
||||
return
|
||||
self.table.delete(*self.table.get_children())
|
||||
for debit in self.debits:
|
||||
for index, debit in enumerate(self.debits):
|
||||
self.table.insert(
|
||||
"", "end",
|
||||
"", "end", iid=str(index),
|
||||
values=(debit.member_number, debit.account_holder, debit.iban,
|
||||
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
|
||||
)
|
||||
total = sum((item.amount for item in self.debits), Decimal("0"))
|
||||
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
|
||||
self.table.selection_set(
|
||||
[
|
||||
str(index)
|
||||
for index, debit in enumerate(self.debits)
|
||||
if debit.member_id not in previously_deselected
|
||||
]
|
||||
)
|
||||
self._update_summary()
|
||||
self.warning_var.set(
|
||||
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
|
||||
f"{len(self.warnings)} Position(en) übersprungen – Details beim Export."
|
||||
if self.warnings
|
||||
else ""
|
||||
)
|
||||
|
||||
def _selected_debits(self) -> list:
|
||||
return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)]
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
selected = self._selected_debits()
|
||||
total = sum((item.amount for item in self.debits), Decimal("0"))
|
||||
if len(selected) == len(self.debits):
|
||||
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
|
||||
return
|
||||
selected_total = sum((item.amount for item in selected), Decimal("0"))
|
||||
self.summary_var.set(
|
||||
f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · "
|
||||
f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)"
|
||||
)
|
||||
|
||||
def _prepare(self) -> tuple[date, date, date] | None:
|
||||
@@ -135,8 +181,13 @@ class SepaExportDialog(tk.Toplevel):
|
||||
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
|
||||
)
|
||||
return None
|
||||
if not self._selected_debits():
|
||||
messagebox.showinfo(
|
||||
"Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", parent=self
|
||||
)
|
||||
return None
|
||||
if self.warnings and not messagebox.askyesno(
|
||||
"Unvollständige Mandate",
|
||||
"Übersprungene Positionen",
|
||||
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
||||
parent=self,
|
||||
):
|
||||
@@ -157,7 +208,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
return
|
||||
target = self._target(".csv", "CSV-Datei", dates[2])
|
||||
if target:
|
||||
write_csv(target, self.debits, collection_date=dates[2])
|
||||
write_csv(target, self._selected_debits(), collection_date=dates[2])
|
||||
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
|
||||
|
||||
def _save_xml(self) -> None:
|
||||
@@ -169,7 +220,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
return
|
||||
try:
|
||||
write_pain008(
|
||||
target, self.debits, collection_date=dates[2],
|
||||
target, self._selected_debits(), collection_date=dates[2],
|
||||
organization=self.repository.get_configuration().get("organization") or {},
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
@@ -180,7 +231,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
def _open_mail_export(self) -> None:
|
||||
dates = self._prepare()
|
||||
if dates:
|
||||
SepaMailExportDialog(self, self.repository, self.debits, dates[2])
|
||||
SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2])
|
||||
|
||||
|
||||
class SepaMailExportDialog(tk.Toplevel):
|
||||
@@ -229,26 +280,33 @@ class SepaMailExportDialog(tk.Toplevel):
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Ordner wählen und Entwürfe erzeugen",
|
||||
text="Info-Mails erzeugen",
|
||||
style="Accent.TButton",
|
||||
command=self._generate,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
self.grab_set()
|
||||
|
||||
def _generate(self) -> None:
|
||||
selected = filedialog.askdirectory(
|
||||
parent=self,
|
||||
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
|
||||
mustexist=True,
|
||||
)
|
||||
if not selected:
|
||||
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||
if delivery_mode is None:
|
||||
return
|
||||
output_directory = None
|
||||
if delivery_mode == "local":
|
||||
selected = filedialog.askdirectory(
|
||||
parent=self,
|
||||
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
|
||||
mustexist=True,
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
output_directory = selected
|
||||
try:
|
||||
generated, warnings = generate_debit_mails(
|
||||
self.repository,
|
||||
self.debits,
|
||||
collection_date=self.collection_date,
|
||||
output_directory=selected,
|
||||
delivery_mode=delivery_mode,
|
||||
output_directory=output_directory,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
@@ -256,10 +314,12 @@ class SepaMailExportDialog(tk.Toplevel):
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
detail = (
|
||||
f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den "
|
||||
"Mitgliederakten archiviert."
|
||||
)
|
||||
location = {
|
||||
"local": f"in\n{output_directory}\nerzeugt",
|
||||
"send": "direkt versandt",
|
||||
"drafts": "als Entwurf im Postfach abgelegt",
|
||||
}[delivery_mode]
|
||||
detail = f"{len(generated)} Info-Mail(s) wurden {location} und in den Mitgliederakten archiviert."
|
||||
if warnings:
|
||||
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
|
||||
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
|
||||
|
||||
@@ -121,7 +121,14 @@ class SplashScreen(tk.Toplevel):
|
||||
self.repository.list_members()
|
||||
member_count = len(self.repository.list_member_ids())
|
||||
findings = []
|
||||
if self.run_housekeeper:
|
||||
# A read-only store makes the housekeeper impossible, not just
|
||||
# unwanted: every one of its passes writes. Skipping it keeps the
|
||||
# rest of the startup -- and the whole read-only session -- working.
|
||||
if self.repository.read_only:
|
||||
self._messages.put(
|
||||
("status", "Store ist schreibgeschützt – Hausmeister übersprungen …")
|
||||
)
|
||||
elif self.run_housekeeper:
|
||||
self._messages.put(("status", "Starte Hausmeister …"))
|
||||
findings = Housekeeper(self.repository, self.housekeeper_settings).run(
|
||||
member_delay=_member_delay_for_splash(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default as default_policy
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import claim_balance, money_text
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.services.welcome_mail import (
|
||||
generate_and_send_welcome_mail,
|
||||
open_claims,
|
||||
welcome_mail_bytes,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||
|
||||
|
||||
class WelcomeMailDialog(tk.Toplevel):
|
||||
"""Sends the welcome mail that doubles as the first invoice for a new member:
|
||||
membership start, member number and every claim that is still open."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
on_sent: Callable[[], None],
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.on_sent = on_sent
|
||||
self.claims: list[dict] = []
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
|
||||
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||
self.summary_var = tk.StringVar()
|
||||
self.title("Willkommens- und Erstrechnungsmail")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(True, True)
|
||||
self._build_ui()
|
||||
self._refresh_claims()
|
||||
self.grab_set()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Begrüßt das neue Mitglied und stellt die noch offenen Forderungen "
|
||||
"(Aufnahmegebühr und erster Beitrag) als Erstrechnung zusammen. Text und "
|
||||
"Betreff stammen aus der Vorlage unter Optionen → E-Mail-Vorlagen."
|
||||
),
|
||||
wraplength=640,
|
||||
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Standardmäßig sind alle offenen Forderungen ausgewählt – einzelne mit "
|
||||
"Strg+Klick abwählen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=640,
|
||||
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
|
||||
table = ttk.Frame(self)
|
||||
table.grid(row=2, column=0, sticky="nsew", padx=14)
|
||||
table.columnconfigure(0, weight=1)
|
||||
table.rowconfigure(0, weight=1)
|
||||
self.table = ttk.Treeview(
|
||||
table,
|
||||
columns=("due", "amount", "title"),
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
height=6,
|
||||
)
|
||||
for key, title, width in (
|
||||
("due", "Fällig", 90),
|
||||
("amount", "Offen", 90),
|
||||
("title", "Forderung", 320),
|
||||
):
|
||||
self.table.heading(key, text=title)
|
||||
self.table.column(key, width=width, anchor="w", stretch=key == "title")
|
||||
self.table.grid(row=0, column=0, sticky="nsew")
|
||||
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
|
||||
scroll.grid(row=0, column=1, sticky="ns")
|
||||
self.table.configure(yscrollcommand=scroll.set)
|
||||
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||
row=3, column=0, sticky="w", padx=14, pady=(6, 10)
|
||||
)
|
||||
|
||||
fields = ttk.Frame(self)
|
||||
fields.grid(row=4, column=0, sticky="ew", padx=14)
|
||||
fields.columnconfigure(1, weight=1)
|
||||
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
|
||||
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
|
||||
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
|
||||
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||
|
||||
buttons = ttk.Frame(self, padding=14)
|
||||
buttons.grid(row=5, column=0, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(buttons, text="Vorschau", command=self._preview).pack(side="left", padx=(8, 0))
|
||||
ttk.Button(
|
||||
buttons, text="E-Mail erzeugen", style="Accent.TButton", command=self._generate
|
||||
).pack(side="left", padx=(8, 0))
|
||||
|
||||
def _refresh_claims(self) -> None:
|
||||
try:
|
||||
self.claims = open_claims(self.repository, self.member_id)
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Forderungen konnten nicht gelesen werden", str(exc), parent=self)
|
||||
return
|
||||
self.table.delete(*self.table.get_children())
|
||||
for index, claim in enumerate(self.claims):
|
||||
self.table.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(
|
||||
format_date_for_display(str(claim.get("due_date", ""))),
|
||||
money_text(claim_balance(data, claim)),
|
||||
str(claim.get("description", "") or claim.get("title", "")),
|
||||
),
|
||||
)
|
||||
self.table.selection_set(self.table.get_children())
|
||||
self._update_summary()
|
||||
|
||||
def _selected_claims(self) -> list[dict]:
|
||||
return [self.claims[int(iid)] for iid in self.table.selection()]
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
selected = self._selected_claims()
|
||||
total = sum((claim_balance(data, claim) for claim in selected), Decimal("0"))
|
||||
self.summary_var.set(
|
||||
f"{len(selected)} von {len(self.claims)} Forderungen ausgewählt – "
|
||||
f"Gesamtbetrag {money_text(total)} Euro"
|
||||
)
|
||||
|
||||
def _mail_bytes(self) -> bytes:
|
||||
member = self.repository.get_member(self.member_id)
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
claims = self._selected_claims()
|
||||
return welcome_mail_bytes(
|
||||
self.repository,
|
||||
member=member,
|
||||
claims=claims,
|
||||
balances={
|
||||
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
|
||||
},
|
||||
organization=self.repository.get_configuration().get("organization") or {},
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
|
||||
def _preview(self) -> None:
|
||||
try:
|
||||
message = BytesParser(policy=default_policy).parsebytes(self._mail_bytes())
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
|
||||
return
|
||||
window = tk.Toplevel(self)
|
||||
window.title("Vorschau der Willkommensmail")
|
||||
window.transient(self)
|
||||
window.columnconfigure(0, weight=1)
|
||||
window.rowconfigure(1, weight=1)
|
||||
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
preview = tk.Text(window, wrap="word", width=88, height=28)
|
||||
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
|
||||
preview.insert("1.0", message.get_content())
|
||||
preview.configure(state="disabled")
|
||||
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
|
||||
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
|
||||
preview.configure(yscrollcommand=scroll.set)
|
||||
ttk.Button(window, text="Schließen", command=window.destroy).grid(
|
||||
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
|
||||
)
|
||||
|
||||
def _generate(self) -> None:
|
||||
selected = self._selected_claims()
|
||||
if not selected and not messagebox.askyesno(
|
||||
"Keine Forderung ausgewählt",
|
||||
"Es ist keine Forderung ausgewählt. Soll die Mail ohne Rechnungspositionen "
|
||||
"erzeugt werden?",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||
if delivery_mode is None:
|
||||
return
|
||||
output_path = None
|
||||
if delivery_mode == "local":
|
||||
chosen = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Willkommensmail als Thunderbird-Entwurf speichern",
|
||||
defaultextension=".eml",
|
||||
initialfile="Willkommen.eml",
|
||||
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not chosen:
|
||||
return
|
||||
output_path = chosen
|
||||
try:
|
||||
result = generate_and_send_welcome_mail(
|
||||
self.repository,
|
||||
self.member_id,
|
||||
claim_ids=[str(claim.get("claim_id", "")) for claim in selected],
|
||||
delivery_mode=delivery_mode,
|
||||
output_path=output_path,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("E-Mail konnte nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
outcome = {
|
||||
"local": f"gespeichert:\n{result.export_path}",
|
||||
"send": "direkt versandt.",
|
||||
"drafts": "als Entwurf im Postfach abgelegt.",
|
||||
}[delivery_mode]
|
||||
messagebox.showinfo(
|
||||
"Willkommensmail erzeugt",
|
||||
f"Die E-Mail wurde {outcome}\n\nEine Kopie liegt in der Mitgliederakte.",
|
||||
parent=self,
|
||||
)
|
||||
self.destroy()
|
||||
self.on_sent()
|
||||
@@ -245,12 +245,14 @@ class MembersTab(ttk.Frame):
|
||||
findings: list[HousekeeperFinding],
|
||||
on_open: Callable[[str], None],
|
||||
on_close: Callable[[], None],
|
||||
on_data_review: Callable[[], None] | None = None,
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.members = members
|
||||
self.findings = findings
|
||||
self.on_open = on_open
|
||||
self.on_close = on_close
|
||||
self.on_data_review = on_data_review
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -262,7 +264,15 @@ class MembersTab(ttk.Frame):
|
||||
ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
|
||||
self.count_var = tk.StringVar()
|
||||
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
|
||||
buttons = ttk.Frame(header)
|
||||
buttons.grid(row=0, column=1, rowspan=2, sticky="e")
|
||||
if self.on_data_review is not None:
|
||||
# The mailing goes to every member, so it belongs where every member is
|
||||
# listed rather than into the already crowded ribbon.
|
||||
ttk.Button(
|
||||
buttons, text="Datenüberprüfung anfragen", command=self.on_data_review
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Tab schließen", command=self.on_close).pack(side="left")
|
||||
filters = _filter_label_frame(self)
|
||||
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||
filter_row = ttk.Frame(filters)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tk_root():
|
||||
"""One Tk root for the whole test session, shared by every UI test module: the
|
||||
icon library binds its images to the first root, so a second one (or a root torn
|
||||
down between tests) invalidates them with 'image "pyimage1" doesn\'t exist'.
|
||||
Skips where there is no display, which keeps the suite running on headless CI."""
|
||||
tk = pytest.importorskip("tkinter")
|
||||
from ccma.ui.theme import load_theme
|
||||
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except tk.TclError as exc:
|
||||
pytest.skip(f"kein Display verfügbar: {exc}")
|
||||
root.withdraw()
|
||||
load_theme(root, "dark")
|
||||
yield root
|
||||
root.destroy()
|
||||
@@ -345,13 +345,13 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
detail="Per E-Mail versandt",
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
assert reminder["status"] == "draft"
|
||||
assert reminder["fee_item_id"] is None
|
||||
assert reminder["fee_item_ids"] == []
|
||||
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"])
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
@@ -360,7 +360,7 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
|
||||
assert claim_total(claim) == Decimal("105.00")
|
||||
assert sent["status"] == "sent"
|
||||
assert sent["payment_deadline"]
|
||||
assert sent["fee_item_id"]
|
||||
assert sent["fee_item_ids"]
|
||||
assert data.reminders[0]["detail"] == "Per E-Mail versandt"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent"
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("tkinter")
|
||||
|
||||
from ccma.storage.repository import MemberRepository # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path / "store")
|
||||
repository.initialize()
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update({"name": "CCC Mannheim e.V.", "email": "verwaltung@example.org"})
|
||||
repository.save_organization(organization)
|
||||
return repository
|
||||
|
||||
|
||||
def _member(repository, first_name, last_name, *, email="", status="active"):
|
||||
member = repository.create_member(
|
||||
first_name=first_name, last_name=last_name, birth_date="1990-01-01"
|
||||
)
|
||||
member.email = email
|
||||
member.status = status
|
||||
repository.save_member(member)
|
||||
return member
|
||||
|
||||
|
||||
def test_the_dialog_preselects_reachable_live_memberships(tk_root, repository):
|
||||
from ccma.ui.data_review_mail_dialog import DataReviewMailDialog
|
||||
|
||||
_member(repository, "Ada", "Lovelace", email="ada@example.org")
|
||||
_member(repository, "Grace", "Hopper", email="grace@example.org", status="ended")
|
||||
_member(repository, "Anon", "Ymous")
|
||||
|
||||
dialog = DataReviewMailDialog(tk_root, repository)
|
||||
try:
|
||||
tk_root.update()
|
||||
selected = [member.display_name for member in dialog._selected_members()]
|
||||
|
||||
assert selected == ["Ada Lovelace"]
|
||||
# Every member stays listed so a resigned one can still be picked by hand.
|
||||
assert len(dialog.table.get_children()) == 3
|
||||
assert "1 von 3 Mitgliedern ausgewählt" in dialog.summary_var.get()
|
||||
assert "1 Mail(s) werden erzeugt" in dialog.summary_var.get()
|
||||
assert "1 Mitglied(er) ohne E-Mail-Adresse" in dialog.warning_var.get()
|
||||
finally:
|
||||
dialog.destroy()
|
||||
tk_root.update()
|
||||
|
||||
|
||||
def test_a_selection_without_an_address_generates_nothing(tk_root, repository, monkeypatch):
|
||||
from tkinter import messagebox
|
||||
|
||||
import ccma.ui.data_review_mail_dialog as module
|
||||
|
||||
_member(repository, "Anon", "Ymous")
|
||||
dialog = module.DataReviewMailDialog(tk_root, repository)
|
||||
try:
|
||||
tk_root.update()
|
||||
dialog.table.selection_set(dialog.table.get_children())
|
||||
informed = []
|
||||
monkeypatch.setattr(messagebox, "showinfo", lambda *args, **kwargs: informed.append(args))
|
||||
monkeypatch.setattr(
|
||||
module, "resolve_delivery_mode", lambda *args: pytest.fail("kein Versand erwartet")
|
||||
)
|
||||
|
||||
dialog._generate()
|
||||
|
||||
assert informed and "Keine Empfänger" in informed[0][0]
|
||||
finally:
|
||||
dialog.destroy()
|
||||
tk_root.update()
|
||||
@@ -129,6 +129,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
|
||||
"Forderung mit Positionen.fodt",
|
||||
"Mahnung.fodt",
|
||||
"Mitglied.fodt",
|
||||
"mail",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import pytest
|
||||
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_email_settings_default_to_local_delivery_for_new_repositories(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
|
||||
assert settings["delivery_mode"] == "local"
|
||||
assert settings["smtp_host"] == ""
|
||||
assert settings["imap_host"] == ""
|
||||
assert settings["imap_sent_enabled"] is False
|
||||
assert settings["imap_sent_folder"] == "INBOX.Sent"
|
||||
|
||||
|
||||
def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
repository.save_email_settings(
|
||||
delivery_mode="ask",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="board@example.org",
|
||||
smtp_password="secret",
|
||||
imap_host="mail.example.org",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="board@example.org",
|
||||
imap_password="secret",
|
||||
imap_drafts_folder="INBOX.Entwürfe",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
assert settings["delivery_mode"] == "ask"
|
||||
assert settings["smtp_host"] == "mail.example.org"
|
||||
assert settings["smtp_port"] == 587
|
||||
assert settings["smtp_password"] == "secret"
|
||||
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||
assert settings["imap_sent_enabled"] is True
|
||||
assert settings["imap_sent_folder"] == "INBOX.Gesendet"
|
||||
|
||||
|
||||
def test_email_settings_sent_copy_requires_imap_host_even_in_send_only_mode(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Gesendet-Ordner ist ein IMAP-Server erforderlich"):
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "match"),
|
||||
[
|
||||
({"delivery_mode": "carrier-pigeon"}, "Ungültiger Versandmodus"),
|
||||
({"smtp_security": "rot13"}, "Ungültige SMTP-Verschlüsselung"),
|
||||
({"imap_security": "rot13"}, "Ungültige IMAP-Verschlüsselung"),
|
||||
({"delivery_mode": "send", "smtp_host": ""}, "SMTP-Server erforderlich"),
|
||||
({"delivery_mode": "drafts", "imap_host": ""}, "IMAP-Server erforderlich"),
|
||||
({"delivery_mode": "ask", "imap_host": ""}, "IMAP-Server erforderlich"),
|
||||
({"smtp_port": 0}, "SMTP-Port"),
|
||||
({"smtp_port": 70000}, "SMTP-Port"),
|
||||
({"imap_port": 0}, "IMAP-Port"),
|
||||
],
|
||||
)
|
||||
def test_email_settings_rejects_invalid_input(tmp_path, overrides, match) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
values = {
|
||||
"delivery_mode": "send",
|
||||
"smtp_host": "mail.example.org",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_username": "",
|
||||
"smtp_password": "",
|
||||
"imap_host": "mail.example.org",
|
||||
"imap_port": 993,
|
||||
"imap_security": "ssl",
|
||||
"imap_username": "",
|
||||
"imap_password": "",
|
||||
"imap_drafts_folder": "",
|
||||
}
|
||||
values.update(overrides)
|
||||
|
||||
with pytest.raises(RepositoryError, match=match):
|
||||
repository.save_email_settings(**values)
|
||||
|
||||
|
||||
def test_email_settings_local_mode_needs_no_server(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
repository.save_email_settings(
|
||||
delivery_mode="local",
|
||||
smtp_host="",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
assert settings["delivery_mode"] == "local"
|
||||
# Blank folder falls back to a sensible default instead of staying empty.
|
||||
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||
@@ -0,0 +1,254 @@
|
||||
import pytest
|
||||
|
||||
from ccma.services import mail_delivery
|
||||
from ccma.storage.repository import RepositoryError
|
||||
|
||||
|
||||
class _FakeSmtpClient:
|
||||
instances: list["_FakeSmtpClient"] = []
|
||||
|
||||
def __init__(self, host, port, **kwargs):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.kwargs = kwargs
|
||||
self.calls: list[str] = []
|
||||
self.login_args: tuple | None = None
|
||||
self.sent_messages: list = []
|
||||
_FakeSmtpClient.instances.append(self)
|
||||
|
||||
def starttls(self, **kwargs):
|
||||
self.calls.append("starttls")
|
||||
|
||||
def login(self, username, password):
|
||||
self.login_args = (username, password)
|
||||
self.calls.append("login")
|
||||
|
||||
def send_message(self, message):
|
||||
self.sent_messages.append(message)
|
||||
self.calls.append("send_message")
|
||||
|
||||
def quit(self):
|
||||
self.calls.append("quit")
|
||||
|
||||
|
||||
class _FakeImapClient:
|
||||
instances: list["_FakeImapClient"] = []
|
||||
default_list_result: tuple = ("OK", [])
|
||||
error = Exception
|
||||
|
||||
def __init__(self, host, port, **kwargs):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.kwargs = kwargs
|
||||
self.calls: list[str] = []
|
||||
self.login_args: tuple | None = None
|
||||
self.appended: list[tuple] = []
|
||||
self.append_result = ("OK", [b"APPEND completed"])
|
||||
self.selected: list[str] = []
|
||||
self.select_result = ("OK", [b"1"])
|
||||
self.created: list[str] = []
|
||||
self.create_result = ("OK", [b"CREATE completed"])
|
||||
self.list_result = _FakeImapClient.default_list_result
|
||||
_FakeImapClient.instances.append(self)
|
||||
|
||||
def starttls(self, *args, **kwargs):
|
||||
self.calls.append("starttls")
|
||||
|
||||
def login(self, username, password):
|
||||
self.login_args = (username, password)
|
||||
self.calls.append("login")
|
||||
|
||||
def append(self, folder, flags, date_time, message_bytes):
|
||||
self.appended.append((folder, flags, date_time, message_bytes))
|
||||
self.calls.append("append")
|
||||
return self.append_result
|
||||
|
||||
def select(self, folder, readonly=False):
|
||||
self.selected.append(folder)
|
||||
self.calls.append("select")
|
||||
return self.select_result
|
||||
|
||||
def create(self, folder):
|
||||
self.created.append(folder)
|
||||
self.calls.append("create")
|
||||
return self.create_result
|
||||
|
||||
def list(self):
|
||||
self.calls.append("list")
|
||||
return self.list_result
|
||||
|
||||
def logout(self):
|
||||
self.calls.append("logout")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_fakes():
|
||||
_FakeSmtpClient.instances.clear()
|
||||
_FakeImapClient.instances.clear()
|
||||
_FakeImapClient.default_list_result = ("OK", [])
|
||||
yield
|
||||
|
||||
|
||||
def _patch_smtp(monkeypatch):
|
||||
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _FakeSmtpClient)
|
||||
monkeypatch.setattr(mail_delivery.smtplib, "SMTP_SSL", _FakeSmtpClient)
|
||||
|
||||
|
||||
def _patch_imap(monkeypatch):
|
||||
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4", _FakeImapClient)
|
||||
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4_SSL", _FakeImapClient)
|
||||
|
||||
|
||||
SMTP_SETTINGS = {
|
||||
"smtp_host": "mail.example.org",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_username": "board@example.org",
|
||||
"smtp_password": "secret",
|
||||
}
|
||||
|
||||
IMAP_SETTINGS = {
|
||||
"imap_host": "mail.example.org",
|
||||
"imap_port": 993,
|
||||
"imap_security": "ssl",
|
||||
"imap_username": "board@example.org",
|
||||
"imap_password": "secret",
|
||||
}
|
||||
|
||||
MESSAGE = (
|
||||
b"From: Board <board@example.org>\r\n"
|
||||
b"To: member@example.org\r\n"
|
||||
b"Subject: Test\r\n"
|
||||
b"\r\n"
|
||||
b"Hello\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) -> None:
|
||||
_patch_smtp(monkeypatch)
|
||||
with mail_delivery.smtp_session(SMTP_SETTINGS) as client:
|
||||
mail_delivery.send_via_smtp(client, MESSAGE)
|
||||
mail_delivery.send_via_smtp(client, MESSAGE)
|
||||
|
||||
assert len(_FakeSmtpClient.instances) == 1
|
||||
fake = _FakeSmtpClient.instances[0]
|
||||
assert fake.host == "mail.example.org"
|
||||
assert fake.port == 587
|
||||
assert fake.login_args == ("board@example.org", "secret")
|
||||
assert fake.calls == ["starttls", "login", "send_message", "send_message", "quit"]
|
||||
assert len(fake.sent_messages) == 2
|
||||
|
||||
|
||||
def test_append_message_uses_configured_folder_and_flags(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
mail_delivery.append_message(client, MESSAGE, folder="INBOX.Entwürfe", flags=r"(\Draft)")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.login_args == ("board@example.org", "secret")
|
||||
# The folder name travels the wire in modified UTF-7, not raw UTF-8.
|
||||
assert fake.appended == [("INBOX.Entw&APw-rfe", r"(\Draft)", None, MESSAGE)]
|
||||
assert fake.calls == ["login", "append", "logout"]
|
||||
|
||||
|
||||
def test_append_message_raises_on_rejected_status(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.append_result = ("NO", [b"Mailbox does not exist"])
|
||||
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
|
||||
mail_delivery.append_message(client, MESSAGE, folder="Missing", flags=r"(\Seen)")
|
||||
|
||||
|
||||
def test_ensure_imap_folder_skips_creation_when_folder_already_exists(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
mail_delivery.ensure_imap_folder(client, "INBOX.Sent")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.selected == ["INBOX.Sent"]
|
||||
assert fake.created == []
|
||||
|
||||
|
||||
def test_ensure_imap_folder_creates_missing_folder(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.select_result = ("NO", [b"Mailbox does not exist"])
|
||||
mail_delivery.ensure_imap_folder(client, "Archiv.Neu")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.selected == ["Archiv.Neu"]
|
||||
assert fake.created == ["Archiv.Neu"]
|
||||
|
||||
|
||||
def test_ensure_imap_folder_raises_when_creation_fails(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.select_result = ("NO", [b"no such mailbox"])
|
||||
client.create_result = ("NO", [b"Permission denied"])
|
||||
with pytest.raises(RepositoryError, match="Permission denied"):
|
||||
mail_delivery.ensure_imap_folder(client, "Verboten")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plain", "wire"),
|
||||
[
|
||||
("INBOX", "INBOX"),
|
||||
("Entwürfe", "Entw&APw-rfe"),
|
||||
("INBOX.Entwürfe", "INBOX.Entw&APw-rfe"),
|
||||
("Test & Foo", "Test &- Foo"),
|
||||
],
|
||||
)
|
||||
def test_imap_utf7_round_trips(plain, wire) -> None:
|
||||
assert mail_delivery._encode_imap_utf7(plain) == wire
|
||||
assert mail_delivery._decode_imap_utf7(wire) == plain
|
||||
|
||||
|
||||
def test_list_imap_folders_parses_and_decodes_list_response(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
_FakeImapClient.default_list_result = (
|
||||
"OK",
|
||||
[
|
||||
rb'(\HasNoChildren) "." INBOX',
|
||||
rb'(\HasNoChildren) "." INBOX.Entw&APw-rfe',
|
||||
rb'(\HasNoChildren) "." "INBOX.Gesendete Objekte"',
|
||||
],
|
||||
)
|
||||
|
||||
folders = mail_delivery.list_imap_folders(IMAP_SETTINGS)
|
||||
|
||||
assert folders == ["INBOX", "INBOX.Entwürfe", "INBOX.Gesendete Objekte"]
|
||||
assert _FakeImapClient.instances[0].calls == ["login", "list", "logout"]
|
||||
|
||||
|
||||
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("connection refused")
|
||||
|
||||
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _boom)
|
||||
with pytest.raises(RepositoryError, match="SMTP-Verbindung fehlgeschlagen"):
|
||||
with mail_delivery.smtp_session({**SMTP_SETTINGS, "smtp_security": "none"}):
|
||||
pass
|
||||
|
||||
|
||||
def test_smtp_session_requires_host() -> None:
|
||||
with pytest.raises(RepositoryError, match="Kein SMTP-Server konfiguriert"):
|
||||
with mail_delivery.smtp_session({}):
|
||||
pass
|
||||
|
||||
|
||||
def test_imap_session_requires_host() -> None:
|
||||
with pytest.raises(RepositoryError, match="Kein IMAP-Server konfiguriert"):
|
||||
with mail_delivery.imap_session({}):
|
||||
pass
|
||||
|
||||
|
||||
def test_test_smtp_connection_succeeds_and_closes(monkeypatch) -> None:
|
||||
_patch_smtp(monkeypatch)
|
||||
mail_delivery.test_smtp_connection(SMTP_SETTINGS)
|
||||
assert _FakeSmtpClient.instances[0].calls == ["starttls", "login", "quit"]
|
||||
|
||||
|
||||
def test_test_imap_connection_succeeds_and_closes(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
mail_delivery.test_imap_connection(IMAP_SETTINGS)
|
||||
assert _FakeImapClient.instances[0].calls == ["login", "logout"]
|
||||
@@ -0,0 +1,217 @@
|
||||
import pytest
|
||||
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplate,
|
||||
MailTemplateBlock,
|
||||
MailTemplateError,
|
||||
MailTemplateSpec,
|
||||
default_mail_template,
|
||||
parse_mail_template,
|
||||
placeholder_entries,
|
||||
render_mail_template,
|
||||
serialize_mail_template,
|
||||
validate_block_markers,
|
||||
validate_mail_template,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_subject_header_round_trips_through_file_format() -> None:
|
||||
text = serialize_mail_template("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
|
||||
|
||||
template = parse_mail_template(text)
|
||||
|
||||
assert text.startswith("Betreff: Hallo {{member.first_name}}\n\n")
|
||||
assert template == MailTemplate("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
|
||||
|
||||
|
||||
def test_shipped_defaults_only_use_declared_placeholders() -> None:
|
||||
for spec in MAIL_TEMPLATES:
|
||||
template = default_mail_template(spec.key)
|
||||
validate_mail_template(spec.key, template.subject, template.body)
|
||||
|
||||
|
||||
def test_rendering_fills_values_and_expands_repeat_blocks() -> None:
|
||||
template = MailTemplate(
|
||||
"Beitrag für {{member.first_name}}",
|
||||
"Hallo {{member.first_name}},\n"
|
||||
"{{#claims}}- {{claim.title}}: {{claim.amount}}{{/claims}}\n"
|
||||
"Summe: {{claims.total}}",
|
||||
)
|
||||
|
||||
rendered = render_mail_template(
|
||||
template,
|
||||
{"member.first_name": "Ada", "claims.total": "75.00"},
|
||||
{
|
||||
"claims": [
|
||||
{"claim.title": "Aufnahmegebühr", "claim.amount": "15.00"},
|
||||
{"claim.title": "Mitgliedsbeitrag", "claim.amount": "60.00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert rendered.subject == "Beitrag für Ada"
|
||||
assert rendered.body == (
|
||||
"Hallo Ada,\n- Aufnahmegebühr: 15.00\n- Mitgliedsbeitrag: 60.00\nSumme: 75.00"
|
||||
)
|
||||
|
||||
|
||||
def test_line_holding_only_an_empty_placeholder_is_dropped() -> None:
|
||||
template = MailTemplate("Betreff", "Erste Zeile\n{{reminder.hint}}\nLetzte Zeile")
|
||||
|
||||
assert render_mail_template(template, {"reminder.hint": ""}).body == "Erste Zeile\nLetzte Zeile"
|
||||
assert (
|
||||
render_mail_template(template, {"reminder.hint": "Hinweis: X"}).body
|
||||
== "Erste Zeile\nHinweis: X\nLetzte Zeile"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_placeholder_is_rejected_while_editing() -> None:
|
||||
with pytest.raises(MailTemplateError, match="member.favourite_colour"):
|
||||
validate_mail_template("welcome", "Betreff", "Hallo {{member.favourite_colour}}")
|
||||
|
||||
|
||||
def test_store_copies_are_seeded_editable_and_resettable(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
path = repository.mail_templates_root / "willkommen.txt"
|
||||
assert path.is_file()
|
||||
|
||||
repository.save_mail_template("welcome", subject="Moin {{member.first_name}}", body="Kurz und knapp.")
|
||||
assert repository.get_mail_template("welcome") == MailTemplate(
|
||||
"Moin {{member.first_name}}", "Kurz und knapp."
|
||||
)
|
||||
assert "Moin {{member.first_name}}" in path.read_text(encoding="utf-8")
|
||||
|
||||
repository.reset_mail_template("welcome")
|
||||
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
|
||||
|
||||
|
||||
def test_missing_template_file_falls_back_to_shipped_default(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
(repository.mail_templates_root / "mahnung.txt").unlink()
|
||||
|
||||
template = repository.get_mail_template("reminder")
|
||||
|
||||
assert template == default_mail_template("reminder")
|
||||
assert (repository.mail_templates_root / "mahnung.txt").is_file()
|
||||
|
||||
|
||||
def test_saving_a_broken_template_is_refused(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Platzhalter"):
|
||||
repository.save_mail_template("reminder", subject="Mahnung", body="Hallo {{member.nonsense}}")
|
||||
with pytest.raises(RepositoryError, match="Betreff"):
|
||||
repository.save_mail_template("reminder", subject=" ", body="Hallo")
|
||||
|
||||
|
||||
def test_block_rows_insert_a_working_block_not_its_description() -> None:
|
||||
entries = placeholder_entries("welcome")
|
||||
block = next(entry for entry in entries if entry.label.startswith("{{#claims}}"))
|
||||
|
||||
# The label may abbreviate the block, the inserted snippet may not.
|
||||
assert block.label == "{{#claims}} … {{/claims}}"
|
||||
assert block.snippet.startswith("{{#claims}}\n")
|
||||
assert block.snippet.rstrip().endswith("{{/claims}}")
|
||||
assert "{{claim.description}}" in block.snippet
|
||||
assert "…" not in block.snippet
|
||||
assert all("…" not in entry.snippet for entry in entries)
|
||||
|
||||
|
||||
def test_inserted_block_validates_and_renders_one_line_per_claim() -> None:
|
||||
block = next(
|
||||
entry for entry in placeholder_entries("welcome") if entry.label.startswith("{{#claims}}")
|
||||
)
|
||||
body = f"Hallo {{{{member.first_name}}}},\n\n{block.snippet}\nSumme: {{{{claims.total}}}}"
|
||||
|
||||
validate_mail_template("welcome", "Betreff", body)
|
||||
rendered = render_mail_template(
|
||||
MailTemplate("Betreff", body),
|
||||
{"member.first_name": "Ada", "claims.total": "75.00"},
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"claim.description": "Aufnahmegebühr",
|
||||
"claim.due_date": "17.09.2026",
|
||||
"claim.balance": "15.00",
|
||||
},
|
||||
{
|
||||
"claim.description": "Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||
"claim.due_date": "17.09.2026",
|
||||
"claim.balance": "60.00",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in rendered.body
|
||||
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in rendered.body
|
||||
assert "…" not in rendered.body
|
||||
|
||||
|
||||
def test_every_block_snippet_is_valid_in_its_own_template() -> None:
|
||||
for spec in MAIL_TEMPLATES:
|
||||
for block in spec.blocks:
|
||||
validate_mail_template(spec.key, "Betreff", block.snippet)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
[
|
||||
("Hallo\n{{#claims}}\n{{claim.title}}", "wird nicht geschlossen"),
|
||||
("Hallo\n{{/claims}}\nGruß", "ohne zugehöriges"),
|
||||
("{{#claims}}\n{{claim.title}}\n{{/claims}}\n{{/claims}}", "ohne zugehöriges"),
|
||||
("{{#claims}}\n{{#claims}}\nx\n{{/claims}}\n{{/claims}}", "verschachtelt"),
|
||||
("{{#members}}\nx\n{{/members}}", "Unbekannter Wiederholungsblock"),
|
||||
("{{ # claims }}\nx", "wird nicht geschlossen"),
|
||||
],
|
||||
)
|
||||
def test_unbalanced_repeat_markers_are_rejected(body, expected) -> None:
|
||||
with pytest.raises(MailTemplateError, match=expected):
|
||||
validate_mail_template("welcome", "Betreff", body)
|
||||
|
||||
|
||||
def test_repeat_markers_in_the_subject_are_rejected() -> None:
|
||||
with pytest.raises(MailTemplateError, match="Betreff"):
|
||||
validate_mail_template("welcome", "Beitrag {{#claims}}", "Hallo")
|
||||
|
||||
|
||||
def test_a_template_without_blocks_rejects_every_marker() -> None:
|
||||
with pytest.raises(MailTemplateError, match="Verfügbar: keine"):
|
||||
validate_mail_template("sepa", "Betreff", "{{#claims}}\nx\n{{/claims}}")
|
||||
|
||||
|
||||
def test_a_closing_marker_must_match_the_open_one(monkeypatch) -> None:
|
||||
two_blocks = MailTemplateSpec(
|
||||
key="two-blocks",
|
||||
label="Test",
|
||||
filename="test.txt",
|
||||
description="",
|
||||
placeholders=(),
|
||||
blocks=(
|
||||
MailTemplateBlock("first", "", (("item.a", ""),), "{{item.a}}"),
|
||||
MailTemplateBlock("second", "", (("item.b", ""),), "{{item.b}}"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("ccma.domain.mail_templates.MAIL_TEMPLATES", (two_blocks,))
|
||||
|
||||
with pytest.raises(MailTemplateError, match="Erwartet wird \\{\\{/first\\}\\}"):
|
||||
validate_block_markers("two-blocks", "Betreff", "{{#first}}\nx\n{{/second}}")
|
||||
|
||||
|
||||
def test_a_broken_template_is_not_stored(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
path = repository.mail_templates_root / "willkommen.txt"
|
||||
before = path.read_text(encoding="utf-8")
|
||||
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
repository.save_mail_template(
|
||||
"welcome", subject="Willkommen", body="Hallo\n{{#claims}}\n{{claim.title}}"
|
||||
)
|
||||
|
||||
assert path.read_text(encoding="utf-8") == before
|
||||
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("tkinter")
|
||||
|
||||
from ccma.config import AppConfig # noqa: E402
|
||||
from ccma.storage.repository import MemberRepository # noqa: E402
|
||||
|
||||
# Root ignores the permission bits that stand in for a read-only mount here.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
|
||||
)
|
||||
|
||||
|
||||
def _set_writable(root, writable: bool) -> None:
|
||||
for path in sorted(root.rglob("*"), reverse=True) + [root]:
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
path.chmod(mode | stat.S_IWUSR if writable else mode & ~stat.S_IWUSR)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_only_window(tk_root, tmp_path):
|
||||
from ccma.ui.main_window import MainWindow
|
||||
|
||||
root_path = tmp_path / "store"
|
||||
repository = MemberRepository(root_path)
|
||||
repository.initialize()
|
||||
repository.create_member(first_name="Ada", last_name="Lovelace", birth_date="1990-01-01")
|
||||
_set_writable(root_path, False)
|
||||
|
||||
window = MainWindow(tk_root, MemberRepository(root_path), AppConfig(store_path=str(root_path)), [], [])
|
||||
window.pack(fill="both", expand=True)
|
||||
tk_root.update()
|
||||
yield window
|
||||
window.destroy()
|
||||
tk_root.title("")
|
||||
tk_root.update()
|
||||
_set_writable(root_path, True)
|
||||
|
||||
|
||||
def test_a_read_only_store_is_marked_everywhere(read_only_window, tk_root):
|
||||
assert "NUR LESEN" in tk_root.title()
|
||||
assert "NUR LESEN" in read_only_window.store_var.get()
|
||||
assert read_only_window.messages.winfo_manager(), "der Warnbanner fehlt"
|
||||
|
||||
|
||||
def test_the_banner_does_not_ask_for_a_restart(read_only_window):
|
||||
banner = read_only_window.messages.winfo_children()[0]
|
||||
text = " ".join(
|
||||
str(child.cget("text"))
|
||||
for child in banner.winfo_children()[0].winfo_children()
|
||||
if "text" in child.keys()
|
||||
)
|
||||
# The banner used to end with "und CCMA neu starten", which is wrong -- the
|
||||
# session recovers in place.
|
||||
assert "Erneut prüfen" in text
|
||||
assert "neu starten" not in text.casefold()
|
||||
assert "Neustart ist dafür nicht nötig" in text
|
||||
|
||||
|
||||
def test_the_data_review_mailing_is_refused_on_a_read_only_store(read_only_window, monkeypatch):
|
||||
from tkinter import messagebox
|
||||
|
||||
warned = []
|
||||
monkeypatch.setattr(messagebox, "showwarning", lambda *args, **kwargs: warned.append(args))
|
||||
|
||||
read_only_window.open_data_review_mail()
|
||||
|
||||
assert warned and "schreibgeschützt" in warned[0][0]
|
||||
assert "Der Versand der Datenüberprüfung ist nicht möglich" in warned[0][1]
|
||||
assert not [
|
||||
child
|
||||
for child in read_only_window.winfo_children()
|
||||
if child.winfo_class() == "Toplevel"
|
||||
]
|
||||
|
||||
|
||||
def test_a_remounted_store_clears_every_read_only_marker(read_only_window, tk_root, monkeypatch):
|
||||
from tkinter import messagebox
|
||||
|
||||
_set_writable(read_only_window.repository.root, True)
|
||||
asked = []
|
||||
monkeypatch.setattr(
|
||||
messagebox, "askyesno", lambda *args, **kwargs: asked.append(args) or False
|
||||
)
|
||||
|
||||
read_only_window._recheck_store_access()
|
||||
tk_root.update()
|
||||
|
||||
assert read_only_window.repository.read_only is False
|
||||
assert "NUR LESEN" not in tk_root.title()
|
||||
assert "NUR LESEN" not in read_only_window.store_var.get()
|
||||
assert not read_only_window.messages.winfo_manager(), "der Warnbanner steht noch"
|
||||
assert read_only_window.status_var.get() == "Der Store ist wieder beschreibbar."
|
||||
assert asked, "der übersprungene Hausmeisterlauf wird nicht angeboten"
|
||||
|
||||
|
||||
def test_the_skipped_housekeeper_run_can_be_started_right_away(
|
||||
read_only_window, tk_root, monkeypatch
|
||||
):
|
||||
from tkinter import messagebox
|
||||
|
||||
_set_writable(read_only_window.repository.root, True)
|
||||
monkeypatch.setattr(messagebox, "askyesno", lambda *args, **kwargs: True)
|
||||
|
||||
read_only_window._recheck_store_access()
|
||||
tk_root.update()
|
||||
|
||||
assert (read_only_window.repository.root / "housekeeper.json").is_file()
|
||||
assert read_only_window.status_var.get().startswith("Hausmeisterlauf beendet")
|
||||
|
||||
|
||||
def test_a_store_that_is_still_read_only_keeps_its_markers(read_only_window, tk_root):
|
||||
read_only_window._recheck_store_access()
|
||||
tk_root.update()
|
||||
|
||||
assert "NUR LESEN" in tk_root.title()
|
||||
assert "NUR LESEN" in read_only_window.store_var.get()
|
||||
assert read_only_window.messages.winfo_manager()
|
||||
assert read_only_window.status_var.get() == "Der Store ist weiterhin schreibgeschützt."
|
||||
@@ -0,0 +1,297 @@
|
||||
from contextlib import contextmanager
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.models import Member
|
||||
from ccma.services.member_data_mail import (
|
||||
data_review_recipients,
|
||||
generate_data_review_mails,
|
||||
mask_iban,
|
||||
member_data_fields,
|
||||
payment_reference,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def _store(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
return repository
|
||||
|
||||
|
||||
def _member(repository, first_name, last_name, **values):
|
||||
member = repository.create_member(
|
||||
first_name=first_name, last_name=last_name, birth_date="1990-01-01"
|
||||
)
|
||||
for key, value in values.items():
|
||||
setattr(member, key, value)
|
||||
repository.save_member(member)
|
||||
return repository.get_member(member.member_id)
|
||||
|
||||
|
||||
def test_the_mail_lists_the_stored_record_of_that_member(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
member = _member(
|
||||
repository,
|
||||
"Ada",
|
||||
"Lovelace",
|
||||
email="ada@example.org",
|
||||
phone="0621 123456",
|
||||
street="Hauptstraße 1",
|
||||
postal_code="68159",
|
||||
city="Mannheim",
|
||||
status="active",
|
||||
membership_started_at="2026-01-01",
|
||||
)
|
||||
|
||||
generated, warnings = generate_data_review_mails(
|
||||
repository,
|
||||
[member.member_id],
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "out",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
message = BytesParser(policy=default).parsebytes(generated[0].export_path.read_bytes())
|
||||
content = message.get_content()
|
||||
assert message["To"] == "ada@example.org"
|
||||
assert "Liebes Mitglied," in content
|
||||
assert "folgende Daten haben wir über dich gespeichert" in content
|
||||
assert "Name: Ada Lovelace" in content
|
||||
assert "E-Mail: ada@example.org" in content
|
||||
assert "Telefon: 0621 123456" in content
|
||||
assert "PLZ und Ort: 68159 Mannheim" in content
|
||||
assert "Mitglied seit: 01.01.2026" in content
|
||||
assert "Zahlweise: Halbjährlich" in content
|
||||
# Both halves identify a transfer on their own, so both are offered.
|
||||
assert "deine Mitgliedsnummer und/oder deinen vollen Namen" in content
|
||||
assert f"{member.member_number} – Ada Lovelace" in content
|
||||
# Nothing bank-related is stored, so the note about the shortened IBAN is left
|
||||
# out instead of pointing at a line that is not there.
|
||||
assert "verkürzt" not in content
|
||||
assert content.rstrip().endswith("Der Vorstand")
|
||||
|
||||
|
||||
def test_a_field_without_a_value_is_shown_as_missing_not_as_a_gap(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
member = _member(repository, "Ada", "Lovelace", email="ada@example.org")
|
||||
|
||||
fields = dict(member_data_fields(member))
|
||||
|
||||
assert fields["Telefon"] == "(nicht hinterlegt)"
|
||||
assert fields["Mitglied seit"] == "(nicht hinterlegt)"
|
||||
# Nothing bank-related is stored, so the member is not asked to check a mandate
|
||||
# they never gave.
|
||||
assert "IBAN" not in fields
|
||||
assert "Mandatsreferenz" not in fields
|
||||
|
||||
|
||||
def test_bank_details_are_listed_with_a_masked_iban(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
member = _member(
|
||||
repository,
|
||||
"Ada",
|
||||
"Lovelace",
|
||||
email="ada@example.org",
|
||||
account_holder="Ada Lovelace",
|
||||
iban="DE98670505050038907751",
|
||||
mandate_reference="MANDAT-42",
|
||||
mandate_signed_at="2026-02-01",
|
||||
mandate_active=True,
|
||||
)
|
||||
|
||||
generated, _warnings = generate_data_review_mails(
|
||||
repository,
|
||||
[member.member_id],
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "out",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
content = BytesParser(policy=default).parsebytes(
|
||||
generated[0].export_path.read_bytes()
|
||||
).get_content()
|
||||
assert "IBAN: DE****************7751" in content
|
||||
assert "DE98670505050038907751" not in content
|
||||
assert "Mandatsreferenz: MANDAT-42" in content
|
||||
assert "Lastschriftmandat: aktiv, erteilt am 01.02.2026" in content
|
||||
assert "(Die IBAN zeigen wir absichtlich nur verkürzt an" in content
|
||||
|
||||
|
||||
def test_the_transfer_reference_joins_number_and_name():
|
||||
member = Member(
|
||||
member_id="1", member_number="CCMA-0007", first_name="Ada", last_name="Lovelace"
|
||||
)
|
||||
|
||||
assert payment_reference(member) == "CCMA-0007 – Ada Lovelace"
|
||||
# The store insists on a member number, but a record read from elsewhere may not
|
||||
# have one -- the name then carries the reference without a dangling dash.
|
||||
assert (
|
||||
payment_reference(
|
||||
Member(member_id="1", member_number="", first_name="Ada", last_name="Lovelace")
|
||||
)
|
||||
== "Ada Lovelace"
|
||||
)
|
||||
|
||||
|
||||
def test_mask_iban_keeps_country_and_last_four_digits():
|
||||
assert mask_iban("DE98 6705 0505 0038 9077 51") == "DE****************7751"
|
||||
# Too short to mask meaningfully -- a broken value is shown as it is stored, so
|
||||
# the member can tell the board it is wrong.
|
||||
assert mask_iban("DE98") == "DE98"
|
||||
|
||||
|
||||
def test_every_selected_member_gets_their_own_mail_and_archive_copy(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
ada = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
|
||||
grace = _member(repository, "Grace", "Hopper", email="grace@example.org", status="active")
|
||||
|
||||
generated, warnings = generate_data_review_mails(
|
||||
repository,
|
||||
[ada.member_id, grace.member_id],
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "out",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert [item.recipient for item in generated] == ["ada@example.org", "grace@example.org"]
|
||||
for item in generated:
|
||||
assert item.archive_path.parent.name == "Datenpruefung"
|
||||
assert item.archive_path.read_bytes() == item.export_path.read_bytes()
|
||||
ada_content = BytesParser(policy=default).parsebytes(
|
||||
generated[0].archive_path.read_bytes()
|
||||
).get_content()
|
||||
assert "Ada Lovelace" in ada_content
|
||||
assert "Grace Hopper" not in ada_content
|
||||
event = repository.get_events(grace.member_id)[-1]
|
||||
assert event.event_type == "data_review_email_sent"
|
||||
assert event.data["recipient"] == "grace@example.org"
|
||||
assert event.references["document"].endswith(".eml")
|
||||
|
||||
|
||||
def test_a_member_without_an_address_is_reported_and_the_run_continues(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
silent = _member(repository, "Anon", "Ymous", status="active")
|
||||
ada = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
|
||||
|
||||
generated, warnings = generate_data_review_mails(
|
||||
repository,
|
||||
[silent.member_id, ada.member_id],
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "out",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert [item.member_id for item in generated] == [ada.member_id]
|
||||
assert warnings == [f"{silent.member_number}: E-Mail-Adresse fehlt."]
|
||||
assert repository.get_events(silent.member_id)[-1].event_type != "data_review_email_sent"
|
||||
|
||||
|
||||
def test_default_recipients_are_the_live_memberships_with_an_address(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
_member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
|
||||
_member(repository, "Grace", "Hopper", email="grace@example.org", status="honorary")
|
||||
_member(repository, "Alan", "Turing", email="alan@example.org", status="ended")
|
||||
_member(repository, "Anon", "Ymous", status="active")
|
||||
|
||||
names = [member.display_name for member in data_review_recipients(repository)]
|
||||
|
||||
assert names == ["Grace Hopper", "Ada Lovelace"]
|
||||
|
||||
|
||||
def test_send_mode_delivers_via_smtp_without_local_files(tmp_path, monkeypatch):
|
||||
import ccma.services.member_data_mail as module
|
||||
|
||||
repository = _store(tmp_path)
|
||||
member = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="smtp.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="verwaltung",
|
||||
smtp_password="secret",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="INBOX.Entwürfe",
|
||||
)
|
||||
sent = []
|
||||
monkeypatch.setattr(module, "smtp_session", contextmanager(lambda settings: iter(["client"])))
|
||||
monkeypatch.setattr(module, "send_via_smtp", lambda client, content: sent.append(content))
|
||||
|
||||
generated, warnings = generate_data_review_mails(
|
||||
repository,
|
||||
[member.member_id],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert generated[0].export_path is None
|
||||
assert generated[0].archive_path.read_bytes() == sent[0]
|
||||
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
|
||||
|
||||
|
||||
def test_a_template_broken_outside_ccma_stops_that_member_without_archiving(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
member = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
|
||||
(repository.mail_templates_root / "datenpruefung.txt").write_text(
|
||||
"Betreff: Datenprüfung\n\nHallo,\n{{#data}}\n{{field.label}}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
generated, warnings = generate_data_review_mails(
|
||||
repository,
|
||||
[member.member_id],
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "out",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated == []
|
||||
assert "wird nicht geschlossen" in warnings[0]
|
||||
archive = repository.members_root / member.member_id / "files" / "documents" / "Datenpruefung"
|
||||
assert not any(archive.glob("*.eml"))
|
||||
assert repository.get_events(member.member_id)[-1].event_type != "data_review_email_sent"
|
||||
|
||||
|
||||
def test_an_unknown_delivery_mode_is_refused(tmp_path):
|
||||
repository = _store(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="Versandmodus"):
|
||||
generate_data_review_mails(
|
||||
repository,
|
||||
[],
|
||||
delivery_mode="postal",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("tkinter")
|
||||
|
||||
from ccma.config import AppConfig # noqa: E402
|
||||
from ccma.domain.mail_templates import placeholder_entries # noqa: E402
|
||||
from ccma.services.welcome_mail import generate_and_send_welcome_mail # noqa: E402
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path / "store")
|
||||
repository.initialize()
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
return repository
|
||||
|
||||
|
||||
def _open_options(tk_root, repository):
|
||||
from ccma.ui.options_dialog import OptionsDialog
|
||||
|
||||
dialog = OptionsDialog(tk_root, AppConfig(store_path=str(repository.root)), repository)
|
||||
tk_root.update()
|
||||
return dialog
|
||||
|
||||
|
||||
def _block_row(dialog):
|
||||
for row in dialog.mail_template_placeholders.get_children():
|
||||
label = str(dialog.mail_template_placeholders.item(row, "values")[0])
|
||||
if label.startswith("{{#claims}}"):
|
||||
return row
|
||||
raise AssertionError("Der Wiederholungsblock fehlt in der Platzhalterliste.")
|
||||
|
||||
|
||||
def test_double_click_on_a_block_is_wired_up(tk_root, repository):
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
assert dialog.mail_template_placeholders.bind("<Double-1>")
|
||||
assert dialog.mail_template_snippets[_block_row(dialog)] == next(
|
||||
entry.snippet
|
||||
for entry in placeholder_entries("welcome")
|
||||
if entry.label.startswith("{{#claims}}")
|
||||
)
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
def test_inserting_a_block_puts_editable_template_code_into_the_body(tk_root, repository):
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
dialog.mail_template_body.delete("1.0", "end")
|
||||
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:")
|
||||
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
|
||||
dialog._insert_mail_placeholder()
|
||||
tk_root.update()
|
||||
body = dialog.mail_template_body.get("1.0", "end-1c")
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
assert "…" not in body
|
||||
assert "{{#claims}}" in body and "{{/claims}}" in body
|
||||
assert "{{claim.description}}" in body
|
||||
# The block was appended behind existing text and still starts on its own line.
|
||||
assert body.splitlines()[0] == "Offen sind aktuell:"
|
||||
assert body.splitlines()[1] == "{{#claims}}"
|
||||
|
||||
|
||||
def test_edited_template_survives_saving_and_produces_real_lines_in_the_mail(
|
||||
tk_root, repository, tmp_path
|
||||
):
|
||||
member = repository.create_member(
|
||||
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||
)
|
||||
member.email = "ada@example.org"
|
||||
member.accepted_at = "2026-08-20"
|
||||
repository.save_member(member)
|
||||
repository.create_manual_claim(
|
||||
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
||||
)
|
||||
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
dialog.mail_template_subject_var.set("Willkommen, {{member.first_name}}")
|
||||
dialog.mail_template_body.delete("1.0", "end")
|
||||
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n")
|
||||
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
|
||||
dialog._insert_mail_placeholder()
|
||||
tk_root.update()
|
||||
dialog._save_mail_templates()
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
content = BytesParser(policy=default).parsebytes(
|
||||
generated.export_path.read_bytes()
|
||||
).get_content()
|
||||
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||
assert "…" not in content
|
||||
|
||||
|
||||
def test_the_dialog_refuses_to_save_an_unbalanced_block(tk_root, repository):
|
||||
stored = repository.mail_templates_root / "willkommen.txt"
|
||||
before = stored.read_text(encoding="utf-8")
|
||||
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
dialog.mail_template_body.delete("1.0", "end")
|
||||
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n{{#claims}}\n{{claim.title}}")
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
dialog._save_mail_templates()
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
assert stored.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_a_broken_template_does_not_half_save_the_others(tk_root, repository):
|
||||
reminder_file = repository.mail_templates_root / "mahnung.txt"
|
||||
before = reminder_file.read_text(encoding="utf-8")
|
||||
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
# Edit a valid template first, then break the second one.
|
||||
dialog.mail_template_var.set("Mahnung")
|
||||
dialog._select_mail_template()
|
||||
dialog.mail_template_body.insert("end", "\nP.S. Bitte Mitgliedsnummer angeben.")
|
||||
dialog.mail_template_var.set("Willkommen & Erstrechnung")
|
||||
dialog._select_mail_template()
|
||||
dialog.mail_template_body.insert("end", "\n{{#claims}}\n{{claim.title}}")
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
dialog._save_mail_templates()
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
assert reminder_file.read_text(encoding="utf-8") == before
|
||||
@@ -0,0 +1,253 @@
|
||||
import os
|
||||
import stat
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.documents import DocumentService
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.services.member_data_mail import generate_data_review_mails
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
from ccma.services.sepa_mail import generate_debit_mails
|
||||
from ccma.services.welcome_mail import generate_and_send_welcome_mail
|
||||
from ccma.storage.repository import MemberRepository, ReadOnlyStoreError
|
||||
|
||||
# Root ignores the permission bits this test relies on, so the read-only mount it
|
||||
# stands in for cannot be simulated there.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
|
||||
)
|
||||
|
||||
|
||||
def _set_writable(root, writable: bool) -> None:
|
||||
mode_add = stat.S_IWUSR
|
||||
for path in sorted(root.rglob("*"), reverse=True) + [root]:
|
||||
current = stat.S_IMODE(path.stat().st_mode)
|
||||
path.chmod(current | mode_add if writable else current & ~mode_add)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_only_store(tmp_path):
|
||||
root = tmp_path / "store"
|
||||
repository = MemberRepository(root)
|
||||
repository.initialize()
|
||||
member = repository.create_member(
|
||||
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||
)
|
||||
# A complete member: without an address the mail services would bail out early
|
||||
# for that reason, and the write they are actually being tested for is never
|
||||
# reached.
|
||||
member.email = "ada@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(
|
||||
member.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "claim-1",
|
||||
"claim_key": "overdue",
|
||||
"title": "Offene Forderung",
|
||||
"amount": "100.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
_set_writable(root, False)
|
||||
yield MemberRepository(root), member
|
||||
_set_writable(root, True)
|
||||
|
||||
|
||||
def test_a_writable_store_is_not_reported_as_read_only(tmp_path):
|
||||
repository = MemberRepository(tmp_path / "store")
|
||||
repository.initialize()
|
||||
|
||||
assert repository.read_only is False
|
||||
|
||||
|
||||
def test_store_directory_that_does_not_exist_yet_counts_as_writable(tmp_path):
|
||||
assert MemberRepository(tmp_path / "not" / "created" / "yet").read_only is False
|
||||
|
||||
|
||||
def test_read_only_store_opens_and_stays_readable(read_only_store):
|
||||
repository, member = read_only_store
|
||||
|
||||
assert repository.read_only is True
|
||||
repository.initialize()
|
||||
assert repository.validate() == []
|
||||
assert [item.member_id for item in repository.list_members()] == [member.member_id]
|
||||
assert repository.get_member(member.member_id).first_name == "Ada"
|
||||
assert repository.get_contributions(member.member_id).claims
|
||||
assert repository.get_events(member.member_id)
|
||||
assert repository.get_configuration()["organization"]["name"]
|
||||
|
||||
|
||||
def test_every_write_is_refused_with_one_clear_message(read_only_store):
|
||||
repository, member = read_only_store
|
||||
stored = repository.get_member(member.member_id)
|
||||
|
||||
for action in (
|
||||
lambda: repository.save_member(stored),
|
||||
lambda: repository.create_member(first_name="Grace", last_name="Hopper"),
|
||||
lambda: repository.create_manual_claim(
|
||||
member.member_id, title="Beitrag", amount="10.00", due_date="2026-09-30"
|
||||
),
|
||||
lambda: repository.save_contributions(member.member_id, ContributionData()),
|
||||
lambda: repository.append_event(
|
||||
member.member_id, event_type="comment", summary="Test", actor_type="user"
|
||||
),
|
||||
lambda: repository.save_organization({"name": "Test", "iban": "", "bic": ""}),
|
||||
lambda: repository.create_asset(label="Beamer"),
|
||||
):
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
action()
|
||||
|
||||
|
||||
def test_housekeeper_refuses_to_run_instead_of_failing_on_its_lock(read_only_store):
|
||||
repository, _member = read_only_store
|
||||
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
Housekeeper(repository).run(today=date(2026, 2, 10))
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
Housekeeper(repository).delete_task("some-key")
|
||||
assert not (repository.root / ".housekeeper.lock").exists()
|
||||
|
||||
|
||||
def test_documents_and_mails_refuse_before_touching_the_member_file(read_only_store, tmp_path):
|
||||
repository, member = read_only_store
|
||||
|
||||
service = DocumentService(repository)
|
||||
template = next(item for item in service.list_templates() if item.name == "Mitglied")
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
service.generate(template, member.member_id, output_name="Test")
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
"reminder-1",
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
generate_debit_mails(
|
||||
repository,
|
||||
[],
|
||||
collection_date=date(2026, 9, 1),
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "sepa",
|
||||
sender_name="Verwaltung",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
generate_data_review_mails(
|
||||
repository,
|
||||
[member.member_id],
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "datenpruefung",
|
||||
sender_name="Verwaltung",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
|
||||
def test_the_welcome_mail_writes_nothing_at_all_when_refused(read_only_store, tmp_path):
|
||||
"""The refusal has to come before rendering, sending and archiving -- not out of
|
||||
the failing write at the end, by which point the mail would already be out."""
|
||||
repository, member = read_only_store
|
||||
export_path = tmp_path / "Willkommen.eml"
|
||||
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=export_path,
|
||||
sender_name="Verwaltung",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert not export_path.exists()
|
||||
files = repository.members_root / member.member_id / "files"
|
||||
assert not (files / "documents" / "Willkommen").exists()
|
||||
assert all(
|
||||
event.event_type != "welcome_email_sent"
|
||||
for event in repository.get_events(member.member_id)
|
||||
)
|
||||
|
||||
|
||||
def test_uninitialized_read_only_store_reports_why_it_cannot_be_opened(tmp_path):
|
||||
root = tmp_path / "empty-store"
|
||||
root.mkdir()
|
||||
root.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
with pytest.raises(ReadOnlyStoreError, match="repository.json"):
|
||||
MemberRepository(root).initialize()
|
||||
finally:
|
||||
root.chmod(stat.S_IRWXU)
|
||||
|
||||
|
||||
def test_remounting_writable_is_picked_up_without_a_restart(read_only_store):
|
||||
repository, _member = read_only_store
|
||||
assert repository.read_only is True
|
||||
|
||||
_set_writable(repository.root, True)
|
||||
|
||||
assert repository.read_only is True, "the cached answer must stay put until refreshed"
|
||||
assert repository.refresh_read_only() is False
|
||||
repository.append_event(
|
||||
_member.member_id,
|
||||
event_type="comment",
|
||||
summary="Wieder beschreibbar",
|
||||
actor_type="user",
|
||||
)
|
||||
|
||||
|
||||
def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
|
||||
"""Mirrors what the splash screen's worker thread does -- this is the sequence
|
||||
that used to abort the whole start with a PermissionError."""
|
||||
repository, member = read_only_store
|
||||
|
||||
repository.initialize()
|
||||
errors = repository.validate()
|
||||
members = repository.list_members()
|
||||
findings = [] if repository.read_only else Housekeeper(repository).run()
|
||||
|
||||
assert errors == []
|
||||
assert [item.member_id for item in members] == [member.member_id]
|
||||
assert findings == []
|
||||
assert repository.member_count() == 1
|
||||
|
||||
|
||||
def test_mail_templates_fall_back_to_the_shipped_defaults(read_only_store):
|
||||
"""A store that predates mail templates has no templates/mail/ directory, and a
|
||||
read-only one cannot get it -- the shipped default has to carry the session."""
|
||||
from ccma.domain.mail_templates import default_mail_template
|
||||
|
||||
repository, _member = read_only_store
|
||||
_set_writable(repository.root, True)
|
||||
for path in repository.mail_templates_root.glob("*.txt"):
|
||||
path.unlink()
|
||||
_set_writable(repository.root, False)
|
||||
|
||||
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
|
||||
assert not list(repository.mail_templates_root.glob("*.txt")), "nothing was written"
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
repository.save_mail_template("welcome", subject="Moin", body="Kurz.")
|
||||
+386
-1
@@ -1,9 +1,12 @@
|
||||
from contextlib import contextmanager
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
import pytest
|
||||
from test_reminders import _overdue_claim_repository
|
||||
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
from ccma.storage.repository import RepositoryError
|
||||
|
||||
|
||||
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||
@@ -35,6 +38,7 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=export_path,
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
@@ -54,6 +58,59 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||
assert generated.archive_path.parent.name == "Mahnungen"
|
||||
|
||||
|
||||
def test_a_dunning_mail_asks_for_a_transfer_even_with_an_active_mandate(tmp_path):
|
||||
"""A member is only dunned once the money did not arrive, so this claim has left
|
||||
the direct-debit run -- "wir ziehen den Betrag ein, du musst nichts tun" would
|
||||
contradict the very letter it accompanies."""
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
member.account_holder = "Reminder Test"
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDAT-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
# The shipped template spells the bank details out; this one uses the ready-made
|
||||
# paragraph, which is what the placeholder chooser offers for a dunning mail.
|
||||
repository.save_mail_template(
|
||||
"reminder",
|
||||
subject="Mahnung: {{claim.title}}",
|
||||
body="Hallo {{member.first_name}},\n\n{{payment.instructions}}\n\n{{signature}}",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
content = BytesParser(policy=default).parsebytes(
|
||||
generated.export_path.read_bytes()
|
||||
).get_content()
|
||||
assert "Bitte überweise den Betrag" in content
|
||||
assert "DE98670505050038907751" in content
|
||||
assert "ziehen den Betrag" not in content
|
||||
|
||||
|
||||
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
@@ -64,7 +121,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
level=1,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
@@ -72,6 +129,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
@@ -81,3 +139,330 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
||||
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
||||
|
||||
|
||||
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
sent = []
|
||||
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
|
||||
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.export_path is None
|
||||
assert generated.archive_path.is_file()
|
||||
assert len(sent) == 1
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.reminders[0]["status"] == "sent"
|
||||
|
||||
|
||||
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="mail.example.org",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
|
||||
)
|
||||
ensured = []
|
||||
appended = []
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module,
|
||||
"append_message",
|
||||
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
||||
)
|
||||
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
||||
|
||||
|
||||
def test_reminder_mail_send_failure_reverts_sent_status(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _fake_smtp_session(settings):
|
||||
yield "smtp-client"
|
||||
|
||||
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
||||
|
||||
def _failing_send(client, content):
|
||||
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
|
||||
|
||||
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", _failing_send)
|
||||
|
||||
with pytest.raises(RepositoryError):
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
stored_reminder = data.reminders[0]
|
||||
assert stored_reminder["status"] == "draft"
|
||||
assert not stored_reminder.get("fee_item_ids")
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent_reverted"
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert not any(
|
||||
item.get("description") == "Mahngebühr" for item in claim.get("items", [])
|
||||
)
|
||||
|
||||
|
||||
def test_reminder_mail_local_write_failure_reverts_sent_status(tmp_path, monkeypatch):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(reminder_mail_module.os, "replace", _boom)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Zahlungserinnerung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.reminders[0]["status"] == "draft"
|
||||
|
||||
|
||||
def test_reminder_mail_archiving_failure_after_smtp_success_does_not_revert(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _fake_smtp_session(settings):
|
||||
yield "smtp-client"
|
||||
|
||||
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
||||
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
|
||||
real_replace = reminder_mail_module.os.replace
|
||||
|
||||
def _flaky_replace(src, dst):
|
||||
# Only the final archive move (into the "Mahnungen" folder) should fail --
|
||||
# the mail has already been handed to send_via_smtp by that point.
|
||||
if str(dst).endswith(".eml") and "Mahnungen" in str(dst):
|
||||
raise OSError("disk full")
|
||||
return real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(reminder_mail_module.os, "replace", _flaky_replace)
|
||||
|
||||
with pytest.raises(RepositoryError, match="zugestellt"):
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
stored_reminder = data.reminders[0]
|
||||
assert stored_reminder["status"] == "sent", "mail was already sent -- must not be un-sent"
|
||||
assert stored_reminder.get("fee_item_ids")
|
||||
events = repository.get_events(member.member_id)
|
||||
assert events[-1].event_type == "reminder_email_sent"
|
||||
assert events[-1].data["archive_error"]
|
||||
assert "document" not in events[-1].references
|
||||
|
||||
|
||||
def test_reminder_mail_archive_dir_creation_failure_reverts_sent_status(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
original_mkdir = Path.mkdir
|
||||
|
||||
def _flaky_mkdir(self, *args, **kwargs):
|
||||
if self.name == "Mahnungen":
|
||||
raise OSError("permission denied")
|
||||
return original_mkdir(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Zahlungserinnerung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
stored_reminder = data.reminders[0]
|
||||
assert stored_reminder["status"] == "draft"
|
||||
assert not stored_reminder.get("fee_item_ids")
|
||||
|
||||
+369
-7
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.contributions import claim_total
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
@@ -29,6 +32,15 @@ def _overdue_claim_repository(tmp_path):
|
||||
return repository, member
|
||||
|
||||
|
||||
def _sepa_member(repository, member):
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
return member
|
||||
|
||||
|
||||
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
@@ -42,7 +54,7 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
fee="0.00",
|
||||
items=[],
|
||||
)
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
||||
@@ -58,6 +70,35 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
assert "Erste Mahnung" in next_task.title
|
||||
|
||||
|
||||
def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
# A reminder was already sent and its own deadline hasn't expired yet -- this is
|
||||
# being handled, so it must not appear as an unaddressed "error"-severity overdue
|
||||
# claim, only as a low-priority note that the existing deadline is still running.
|
||||
assert not any(item.code == "claim_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Zahlungserinnerung" in awaiting.title
|
||||
|
||||
|
||||
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
@@ -104,11 +145,7 @@ def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) ->
|
||||
|
||||
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
_sepa_member(repository, member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
@@ -120,6 +157,70 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
||||
assert "SEPA" in sepa_finding.detail
|
||||
|
||||
|
||||
def test_a_dunned_sepa_claim_continues_in_the_normal_dunning_process(tmp_path) -> None:
|
||||
"""A bounced direct debit is dunned like any other claim: the money is expected as
|
||||
a transfer from then on, so the "check the direct debit" notice must not come back
|
||||
once the Rücklastschrift deadline has passed."""
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
_sepa_member(repository, member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Rücklastschrift" in awaiting.title
|
||||
|
||||
# Deadline gone by without payment: the next dunning level is due, not another
|
||||
# "der Einzug muss geprüft werden".
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline + timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
||||
assert "Erste Mahnung" in reminder_task.title
|
||||
assert "Mahnstufe 2" in reminder_task.detail
|
||||
|
||||
|
||||
def test_an_undunned_sepa_claim_still_asks_to_check_the_debit(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
_sepa_member(repository, member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
findings = [
|
||||
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
# A draft alone changes nothing -- only a reminder that actually went out takes the
|
||||
# claim out of the direct-debit run.
|
||||
repository.create_reminder_draft(
|
||||
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||
)
|
||||
still_sepa = [
|
||||
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
assert {item.code for item in findings} == {"sepa_debit_overdue"}
|
||||
assert {item.code for item in still_sepa} == {"sepa_debit_overdue"}
|
||||
|
||||
|
||||
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
@@ -155,7 +256,7 @@ def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
|
||||
level=2,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
|
||||
@@ -173,3 +274,264 @@ def test_payment_resolves_open_reminder_task(tmp_path) -> None:
|
||||
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
assert not any(item.code == "reminder_due" for item in findings)
|
||||
|
||||
|
||||
def test_reminder_can_carry_multiple_fee_items(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[
|
||||
{"description": "Rücklastschriftgebühr", "amount": "5.00"},
|
||||
{"description": "Bankgebühr", "amount": "3.50"},
|
||||
],
|
||||
)
|
||||
assert draft["fee_item_ids"] == []
|
||||
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert len(sent["fee_item_ids"]) == 2
|
||||
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
|
||||
assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
|
||||
assert claim_total(claim) == Decimal("108.50")
|
||||
|
||||
|
||||
def test_reminder_items_require_a_description(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="Beschreibung"):
|
||||
repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": " ", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
|
||||
def test_reminder_with_zero_amount_items_creates_no_claim_item(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "0.00"}],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert sent["fee_item_ids"] == []
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
|
||||
|
||||
def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=10,
|
||||
levels=[
|
||||
{"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
|
||||
{"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
|
||||
],
|
||||
standard_fee_items=[
|
||||
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
|
||||
],
|
||||
)
|
||||
|
||||
policy = repository.get_reminder_policy()
|
||||
assert policy["grace_days_after_due"] == 10
|
||||
assert [level["level"] for level in policy["levels"]] == [1, 2]
|
||||
assert policy["levels"][1]["name"] == "Mahnung"
|
||||
assert policy["levels"][1]["fee"] == "7.50"
|
||||
assert policy["standard_fee_items"] == [
|
||||
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
|
||||
]
|
||||
|
||||
|
||||
def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
|
||||
repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])
|
||||
|
||||
with pytest.raises(RepositoryError, match="benötigt einen Namen"):
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=7,
|
||||
levels=[{"name": " ", "fee": "0.00", "payment_deadline_days": 14}],
|
||||
standard_fee_items=[],
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht negativ"):
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=7,
|
||||
levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
|
||||
standard_fee_items=[],
|
||||
)
|
||||
|
||||
|
||||
def test_edit_reminder_draft_updates_fields_but_not_level(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
|
||||
edited = repository.edit_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
draft["reminder_id"],
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=10,
|
||||
detail="Bitte um Ausgleich.",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
assert edited["level"] == 1
|
||||
assert edited["name"] == "Erste Mahnung"
|
||||
assert edited["payment_deadline_days"] == 10
|
||||
assert edited["detail"] == "Bitte um Ausgleich."
|
||||
assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]
|
||||
|
||||
|
||||
def test_edit_reminder_draft_rejects_already_sent_reminder(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
|
||||
with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
|
||||
repository.edit_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
draft["reminder_id"],
|
||||
name="Andere Bezeichnung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
|
||||
def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert len(sent["fee_item_ids"]) == 1
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("105.00")
|
||||
|
||||
reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert reverted["status"] == "draft"
|
||||
assert reverted["sent_at"] is None
|
||||
assert reverted["payment_deadline"] is None
|
||||
assert reverted["fee_item_ids"] == []
|
||||
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
descriptions = {item["description"] for item in claim["items"]}
|
||||
assert "Rücklastschriftgebühr" not in descriptions
|
||||
|
||||
# Now editable and re-sendable, e.g. after fixing the wording.
|
||||
repository.edit_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
draft["reminder_id"],
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("107.50")
|
||||
|
||||
|
||||
def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
first = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
|
||||
repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=2,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
|
||||
repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
|
||||
|
||||
|
||||
def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-02-10",
|
||||
amount="105.00",
|
||||
allocation_amount="105.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
|
||||
repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
|
||||
|
||||
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
config_path = tmp_path / "repository.json"
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
# Simulate a repository created before "standard_fee_items" existed at all.
|
||||
del raw["reminder_policy"]["standard_fee_items"]
|
||||
config_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
policy = repository.get_reminder_policy()
|
||||
assert policy["standard_fee_items"], "should fall back to built-in defaults"
|
||||
assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"])
|
||||
|
||||
# But once the board explicitly saves an empty list, that choice is respected.
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=policy["grace_days_after_due"],
|
||||
levels=policy["levels"],
|
||||
standard_fee_items=[],
|
||||
)
|
||||
assert repository.get_reminder_policy()["standard_fee_items"] == []
|
||||
|
||||
+98
-1
@@ -8,7 +8,7 @@ import pytest
|
||||
import ccma.services.housekeeper as housekeeper_module
|
||||
from ccma.rules.loader import RuleLoadError
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_housekeeper_optionally_waits_between_members(tmp_path, monkeypatch) -> None:
|
||||
@@ -236,6 +236,39 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -
|
||||
assert "membership-fee:2027:annual" not in claim_keys
|
||||
|
||||
|
||||
def test_housekeeper_skips_birthday_and_anniversary_for_ended_members(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
active = repository.create_member(first_name="Active", last_name="Member", birth_date="1990-06-21")
|
||||
active.status = "active"
|
||||
active.accepted_at = "2025-06-21"
|
||||
active.membership_started_at = "2025-06-21"
|
||||
repository.save_member(active)
|
||||
ended = repository.create_member(first_name="Ended", last_name="Member", birth_date="1990-06-21")
|
||||
ended.status = "ended"
|
||||
ended.accepted_at = "2025-06-21"
|
||||
ended.membership_started_at = "2025-06-21"
|
||||
ended.membership_ended_at = "2025-12-31"
|
||||
repository.save_member(ended)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
birthday_days_before=0,
|
||||
birthday_days_after=0,
|
||||
anniversary_days_before=0,
|
||||
anniversary_days_after=0,
|
||||
anniversary_intervals="1Y",
|
||||
)
|
||||
|
||||
findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21))
|
||||
codes_by_member = {}
|
||||
for item in findings:
|
||||
codes_by_member.setdefault(item.member_id, set()).add(item.code)
|
||||
|
||||
assert "birthday" in codes_by_member.get(active.member_id, set())
|
||||
assert "membership_anniversary" in codes_by_member.get(active.member_id, set())
|
||||
assert codes_by_member.get(ended.member_id, set()).isdisjoint({"birthday", "membership_anniversary"})
|
||||
|
||||
|
||||
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -381,3 +414,67 @@ def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> Non
|
||||
|
||||
assert findings == []
|
||||
assert not any(item.get("member_id") == member.member_id for item in state["items"])
|
||||
|
||||
|
||||
def test_housekeeper_run_can_target_a_single_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member_a = repository.create_member(first_name="Alpha", last_name="Member")
|
||||
member_b = repository.create_member(first_name="Beta", last_name="Member")
|
||||
housekeeper = Housekeeper(repository)
|
||||
housekeeper.run(today=date(2026, 6, 21))
|
||||
state_before = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
|
||||
b_task_before = next(
|
||||
item for item in state_before["items"] if item.get("member_id") == member_b.member_id
|
||||
)
|
||||
|
||||
member_a.birth_date = "1990-01-01"
|
||||
repository.save_member(member_a)
|
||||
findings = housekeeper.run(today=date(2026, 6, 21), member_id=member_a.member_id)
|
||||
|
||||
a_findings = [item for item in findings if item.member_id == member_a.member_id]
|
||||
assert not any(item.code == "missing_birth_date" for item in a_findings)
|
||||
state_after = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
|
||||
b_task_after = next(
|
||||
item for item in state_after["items"] if item.get("member_id") == member_b.member_id
|
||||
)
|
||||
assert b_task_after == b_task_before
|
||||
|
||||
|
||||
def test_housekeeper_run_rejects_unknown_member_id(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht gefunden"):
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id="does-not-exist")
|
||||
|
||||
|
||||
def test_housekeeper_run_retroactive_override_applies_only_to_that_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Retro", last_name="Member", birth_date="1990-01-01")
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id=member.member_id, retroactive_claims=True)
|
||||
|
||||
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
|
||||
assert "membership-fee:2024:annual" in claim_keys
|
||||
assert housekeeper.settings.retroactive_claims is False
|
||||
|
||||
other = repository.create_member(first_name="NoRetro", last_name="Member", birth_date="1990-01-01")
|
||||
other.status = "active"
|
||||
other.accepted_at = "2024-04-15"
|
||||
other.membership_started_at = "2024-04-15"
|
||||
other.payment_frequency = "annual"
|
||||
repository.save_member(other)
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id=other.member_id)
|
||||
|
||||
other_claim_keys = {claim["claim_key"] for claim in repository.get_contributions(other.member_id).claims}
|
||||
assert "membership-fee:2024:annual" not in other_claim_keys
|
||||
assert "membership-fee:2026:annual" in other_claim_keys
|
||||
|
||||
+491
-3
@@ -1,4 +1,5 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from email.parser import BytesParser
|
||||
@@ -62,6 +63,48 @@ def test_pending_debits_select_due_open_balance(tmp_path):
|
||||
assert debits[0].claim_ids == ("due",)
|
||||
|
||||
|
||||
def test_a_dunned_claim_is_not_collected_again(tmp_path):
|
||||
"""The dunning letter asked the member to transfer the money -- pulling it by
|
||||
direct debit anyway would contradict it (and can bounce a second time)."""
|
||||
repository, member = _repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"due",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
# A draft alone is not a dunning: nothing has gone out yet.
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert [debit.claim_ids for debit in debits] == [("due",)]
|
||||
assert warnings == []
|
||||
|
||||
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
# Nothing else is due in the window, so the member drops out of the run entirely
|
||||
# -- and says why instead of vanishing silently.
|
||||
assert debits == []
|
||||
assert warnings == ["C3-42: „Mitgliedsbeitrag 2026“ wurde bereits gemahnt – "
|
||||
"Zahlung per Überweisung erwartet, kein erneuter Einzug."]
|
||||
|
||||
|
||||
def test_a_dunned_claim_does_not_hold_back_the_members_other_claims(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id, "due", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
|
||||
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2027, 12, 31))
|
||||
|
||||
assert [debit.claim_ids for debit in debits] == [("future",)]
|
||||
assert debits[0].amount == Decimal("150.00")
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
member.accepted_at = "2025-01-01"
|
||||
@@ -177,11 +220,11 @@ def test_debit_mail_is_thunderbird_draft(tmp_path):
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
repository,
|
||||
member=member,
|
||||
debit=debit,
|
||||
collection_date=date(2026, 8, 3),
|
||||
creditor_id="DE98ZZZ09999999999",
|
||||
organization={"name": "C3MA", "creditor_id": "DE98ZZZ09999999999"},
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
@@ -214,6 +257,7 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "mail-export",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
@@ -227,3 +271,447 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "sepa_notification_generated"
|
||||
assert event.references["document"].startswith("documents/SEPA/")
|
||||
|
||||
|
||||
def test_generated_mail_send_mode_reuses_one_smtp_connection_for_batch(tmp_path, monkeypatch):
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
|
||||
repository, member = _repository(tmp_path)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
connections_opened = []
|
||||
|
||||
@contextmanager
|
||||
def fake_smtp_session(settings):
|
||||
connections_opened.append(settings)
|
||||
yield object()
|
||||
|
||||
sent = []
|
||||
monkeypatch.setattr(sepa_mail_module, "smtp_session", fake_smtp_session)
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||
)
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert generated[0].export_path is None
|
||||
assert generated[0].archive_path.is_file()
|
||||
assert len(connections_opened) == 1, "one connection should be reused for the whole batch"
|
||||
assert len(sent) == len(debits)
|
||||
|
||||
|
||||
def test_generated_mail_send_mode_copies_to_sent_folder_reusing_one_imap_connection(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
|
||||
repository, ada = _repository(tmp_path)
|
||||
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||
second.status = "active"
|
||||
second.email = "grace@example.org"
|
||||
second.account_holder = "Grace Hopper"
|
||||
second.iban = "DE89370400440532013000"
|
||||
second.bic = "COBADEFFXXX"
|
||||
second.mandate_reference = "MANDAT-43"
|
||||
second.mandate_signed_at = "2025-01-10"
|
||||
second.mandate_active = True
|
||||
repository.save_member(second)
|
||||
repository.save_contributions(
|
||||
second.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="mail.example.org",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert len(debits) == 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
imap_connections = []
|
||||
|
||||
@contextmanager
|
||||
def fake_imap_session(settings):
|
||||
imap_connections.append(settings)
|
||||
yield "imap-client"
|
||||
|
||||
monkeypatch.setattr(sepa_mail_module, "imap_session", fake_imap_session)
|
||||
ensured = []
|
||||
appended = []
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append(folder)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module,
|
||||
"append_message",
|
||||
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
||||
)
|
||||
|
||||
generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert len(imap_connections) == 1, "one IMAP connection should be reused for the whole batch"
|
||||
assert ensured == ["INBOX.Gesendet"]
|
||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")] * 2
|
||||
|
||||
|
||||
def test_generated_mail_archiving_failure_after_smtp_success_keeps_batch_going(tmp_path, monkeypatch):
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
|
||||
repository, ada = _repository(tmp_path)
|
||||
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||
second.status = "active"
|
||||
second.email = "grace@example.org"
|
||||
second.account_holder = "Grace Hopper"
|
||||
second.iban = "DE89370400440532013000"
|
||||
second.bic = "COBADEFFXXX"
|
||||
second.mandate_reference = "MANDAT-43"
|
||||
second.mandate_signed_at = "2025-01-10"
|
||||
second.mandate_active = True
|
||||
repository.save_member(second)
|
||||
repository.save_contributions(
|
||||
second.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert len(debits) == 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
sent = []
|
||||
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content))
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
original_write_bytes = Path.write_bytes
|
||||
|
||||
def _flaky_write_bytes(self, data):
|
||||
if "documents" in str(self) and "C3-43" in self.name:
|
||||
raise OSError("disk full")
|
||||
return original_write_bytes(self, data)
|
||||
|
||||
monkeypatch.setattr(Path, "write_bytes", _flaky_write_bytes)
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
# Both mails were handed to SMTP -- a broken archive write for one of them must
|
||||
# not swallow the other, nor pretend the failing one was never sent.
|
||||
assert len(sent) == 2
|
||||
assert len(generated) == 2
|
||||
assert len(warnings) == 1
|
||||
assert "C3-43" in warnings[0] or "Grace" in warnings[0]
|
||||
|
||||
failing = next(g for g in generated if g.member_id == second.member_id)
|
||||
assert not failing.archive_path.is_file()
|
||||
succeeding = next(g for g in generated if g.member_id == ada.member_id)
|
||||
assert succeeding.archive_path.is_file()
|
||||
|
||||
grace_event = repository.get_events(second.member_id)[-1]
|
||||
assert grace_event.event_type == "sepa_notification_generated"
|
||||
assert grace_event.data["archive_error"]
|
||||
assert "document" not in grace_event.references
|
||||
ada_event = repository.get_events(ada.member_id)[-1]
|
||||
assert "document" in ada_event.references
|
||||
|
||||
|
||||
def test_generated_mail_smtp_failure_for_one_debit_does_not_abort_the_batch(tmp_path, monkeypatch):
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
from ccma.storage.repository import RepositoryError
|
||||
|
||||
repository, ada = _repository(tmp_path)
|
||||
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||
second.status = "active"
|
||||
second.email = "grace@example.org"
|
||||
second.account_holder = "Grace Hopper"
|
||||
second.iban = "DE89370400440532013000"
|
||||
second.bic = "COBADEFFXXX"
|
||||
second.mandate_reference = "MANDAT-43"
|
||||
second.mandate_signed_at = "2025-01-10"
|
||||
second.mandate_active = True
|
||||
repository.save_member(second)
|
||||
repository.save_contributions(
|
||||
second.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert len(debits) == 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
sent = []
|
||||
|
||||
def _flaky_send(client, content):
|
||||
if b"ada@example.org" in content:
|
||||
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
|
||||
sent.append(content)
|
||||
|
||||
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", _flaky_send)
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert len(sent) == 1
|
||||
assert len(generated) == 1
|
||||
assert generated[0].member_id == second.member_id
|
||||
assert len(warnings) == 1
|
||||
assert "connection reset" in warnings[0]
|
||||
ada_events = repository.get_events(ada.member_id)
|
||||
assert all(event.event_type != "sepa_notification_generated" for event in ada_events)
|
||||
|
||||
|
||||
def test_generated_mail_archive_dir_creation_failure_for_one_debit_does_not_abort_the_batch(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from pathlib import Path
|
||||
|
||||
repository, ada = _repository(tmp_path)
|
||||
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||
second.status = "active"
|
||||
second.email = "grace@example.org"
|
||||
second.account_holder = "Grace Hopper"
|
||||
second.iban = "DE89370400440532013000"
|
||||
second.bic = "COBADEFFXXX"
|
||||
second.mandate_reference = "MANDAT-43"
|
||||
second.mandate_signed_at = "2025-01-10"
|
||||
second.mandate_active = True
|
||||
repository.save_member(second)
|
||||
repository.save_contributions(
|
||||
second.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert len(debits) == 2
|
||||
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
sent = []
|
||||
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content))
|
||||
|
||||
original_mkdir = Path.mkdir
|
||||
|
||||
def _flaky_mkdir(self, *args, **kwargs):
|
||||
if self.name == "SEPA" and second.member_id in str(self):
|
||||
raise OSError("permission denied")
|
||||
return original_mkdir(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
# Grace's archive directory could not even be created -- nothing was sent to her,
|
||||
# so she must NOT show up as delivered, but Ada's mail must still go out.
|
||||
assert len(sent) == 1
|
||||
assert len(generated) == 1
|
||||
assert generated[0].member_id == ada.member_id
|
||||
assert len(warnings) == 1
|
||||
assert "Grace" in warnings[0] or "C3-43" in warnings[0]
|
||||
|
||||
@@ -2,6 +2,7 @@ def test_ui_modules_import_without_creating_root_window() -> None:
|
||||
import ccma.app # noqa: F401
|
||||
import ccma.ui.asset_tab # noqa: F401
|
||||
import ccma.ui.claim_tab # noqa: F401
|
||||
import ccma.ui.data_review_mail_dialog # noqa: F401
|
||||
import ccma.ui.main_window # noqa: F401
|
||||
import ccma.ui.member_tab # noqa: F401
|
||||
import ccma.ui.splash # noqa: F401
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
from contextlib import contextmanager
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def _new_member_repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
member = repository.create_member(
|
||||
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||
)
|
||||
member.email = "ada@example.org"
|
||||
member.accepted_at = "2026-08-20"
|
||||
repository.save_member(member)
|
||||
repository.create_manual_claim(
|
||||
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
||||
)
|
||||
repository.create_manual_claim(
|
||||
member.member_id,
|
||||
title="Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||
amount="60.00",
|
||||
due_date="2026-09-17",
|
||||
)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
export_path = tmp_path / "Willkommen.eml"
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=export_path,
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
content = message.get_content()
|
||||
assert message["To"] == "ada@example.org"
|
||||
assert "Willkommen" in message["Subject"]
|
||||
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
|
||||
assert "Gesamtbetrag: 75.00 Euro" in content
|
||||
assert "DE98670505050038907751" in content
|
||||
assert content.rstrip().endswith("Der Vorstand")
|
||||
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
|
||||
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
|
||||
|
||||
|
||||
def test_welcome_mail_is_archived_and_logged(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.archive_path.parent.name == "Willkommen"
|
||||
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "welcome_email_sent"
|
||||
assert event.data["recipient"] == "ada@example.org"
|
||||
assert len(event.data["claim_ids"]) == 2
|
||||
|
||||
|
||||
def test_only_selected_claims_are_billed(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
admission = next(
|
||||
claim for claim in open_claims(repository, member.member_id)
|
||||
if claim["title"] == "Aufnahmegebühr"
|
||||
)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
claim_ids=[admission["claim_id"]],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
content = BytesParser(policy=default).parsebytes(
|
||||
generated.export_path.read_bytes()
|
||||
).get_content()
|
||||
assert "Aufnahmegebühr" in content
|
||||
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
|
||||
assert "Gesamtbetrag: 15.00 Euro" in content
|
||||
|
||||
|
||||
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
member.account_holder = "Ada Lovelace"
|
||||
member.iban = "DE98670505050038907751"
|
||||
member.mandate_reference = "MANDAT-42"
|
||||
member.mandate_signed_at = "2026-08-20"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
content = BytesParser(policy=default).parsebytes(
|
||||
generated.export_path.read_bytes()
|
||||
).get_content()
|
||||
assert "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
|
||||
assert "Bitte überweise" not in content
|
||||
|
||||
|
||||
def test_edited_template_is_used_for_the_mail(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
repository.save_mail_template(
|
||||
"welcome",
|
||||
subject="Servus {{member.first_name}}",
|
||||
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
|
||||
)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert message["Subject"] == "Servus Ada"
|
||||
assert message.get_content().replace("\r\n", "\n").strip() == (
|
||||
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
|
||||
)
|
||||
|
||||
|
||||
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||
import ccma.services.welcome_mail as welcome_mail_module
|
||||
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="smtp.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="verwaltung",
|
||||
smtp_password="secret",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="INBOX.Entwürfe",
|
||||
)
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||
)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.export_path is None
|
||||
assert generated.archive_path.read_bytes() == sent[0]
|
||||
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
|
||||
|
||||
|
||||
def test_member_without_email_is_refused(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
member.email = ""
|
||||
repository.save_member(member)
|
||||
|
||||
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
|
||||
generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
|
||||
def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path, monkeypatch):
|
||||
import ccma.services.welcome_mail as welcome_mail_module
|
||||
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||
)
|
||||
monkeypatch.setattr(welcome_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
real_replace = welcome_mail_module.os.replace
|
||||
|
||||
def _flaky_replace(src, dst):
|
||||
# Only the final move into the member file fails -- the mail is out by then.
|
||||
if str(dst).endswith(".eml") and "Willkommen" in str(dst):
|
||||
raise OSError("disk full")
|
||||
return real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(welcome_mail_module.os, "replace", _flaky_replace)
|
||||
|
||||
with pytest.raises(RepositoryError, match="zugestellt"):
|
||||
generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "welcome_email_sent"
|
||||
assert event.data["archive_error"]
|
||||
assert "document" not in event.references
|
||||
|
||||
|
||||
def test_a_template_broken_outside_ccma_stops_the_send(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
# The templates are plain files in the store, so they can be edited (and broken)
|
||||
# with any text editor -- the send has to notice instead of mailing the marker.
|
||||
(repository.mail_templates_root / "willkommen.txt").write_text(
|
||||
"Betreff: Willkommen\n\nHallo,\n{{#claims}}\n{{claim.title}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert not (tmp_path / "Willkommen.eml").exists()
|
||||
archive = repository.members_root / member.member_id / "files" / "documents" / "Willkommen"
|
||||
assert not archive.exists()
|
||||
assert repository.get_events(member.member_id)[-1].event_type != "welcome_email_sent"
|
||||
Reference in New Issue
Block a user