19 Commits
Author SHA1 Message Date
Git-CI 26a54647aa ci: release version 0.2.0 [skip ci] 2026-09-05 00:34:52 +02:00
Marcel Peterkau 7aa8a2c979 Merge pull request 'Release 0.2.0' (#27) from dev into main
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/27
2026-09-04 22:27:07 +00:00
Git-CI cd49c04816 ci: bump dev version to 0.1.0-dev20 [skip ci] 2026-09-05 00:25:43 +02:00
Marcel Peterkau 2409b4d123 Merge pull request 'Keep dunning a bounced debit out of the direct-debit track' (#26) from feature/dunning-after-failed-debit into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/26
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-09-04 22:24:05 +00:00
Marcel Peterkau 6a1f305202 Merge branch 'dev' into feature/dunning-after-failed-debit 2026-09-04 22:23:04 +00:00
Git-CI 2137ff7c84 ci: bump dev version to 0.1.0-dev19 [skip ci] 2026-09-05 00:22:07 +02:00
Marcel Peterkau 3e949566ac Merge branch 'dev' into feature/dunning-after-failed-debit 2026-09-04 22:19:51 +00:00
Marcel Peterkau 0abb93c021 Merge pull request 'Feature/data review mailing' (#25) from feature/data-review-mailing into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/25
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-09-04 22:19:04 +00:00
Marcel PeterkauandClaude Opus 5 f80b17c443 Keep dunning a bounced debit out of the direct-debit track
Once the direct debit bounced and the board sent the Rücklastschrift reminder,
the claim is expected as a transfer by the deadline that letter states. Three
places still treated it as a claim the mandate covers, and the housekeeper's was
the one the board kept running into: after the reminder's deadline lapsed, the
finding went back to "Lastschrift überfällig -- Einzug prüfen, eine postalische
Mahnung ist hier nicht vorgesehen", for a claim that had just been dunned.

The rule now asks whether the claim was dunned before treating it as one for the
direct debit. If it was, it continues in the ordinary dunning sequence: the
running deadline shows as the usual "Frist läuft noch" note, and once that has
passed the next dunning level comes due. The SEPA-specific pending-reminder
detour that used to cover the deadline window is gone with it -- the ordinary
path reports the same thing.

The SEPA run now skips a dunned claim as well, instead of quietly collecting the
money the letter asked the member to transfer (which can bounce a second time,
with a second fee). The skip is reported like the incomplete mandates are, so
nothing disappears from the run without saying why; the dialog's wording is no
longer specific to mandates.

And a dunning mail asks for a transfer even from a member with an active
mandate. The shipped template spells the bank details out, but the ready-made
{{payment.instructions}} paragraph, offered by the template editor for exactly
this mail, told them "wir ziehen den Betrag ein, du musst nichts weiter tun" --
in the letter demanding payment.

Reverting the sent reminder is what puts the claim back into the direct-debit
run; the read of "dunned" is a sent reminder, not a draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:16:26 +02:00
Marcel PeterkauandClaude Opus 5 31c1646571 Tell members which reference to put on a transfer
A data-review mail that lists the payment frequency but not how to pay leaves the
board matching anonymous transfers by hand. The mail now closes with the
reference to quote -- member number and full name, joined only where both exist
so a missing half cannot leave a dangling dash. Both are offered because either
identifies the payment on its own: a member who has forgotten their number falls
back on the name, and two members sharing a name are told apart by the number.

The note about the shortened IBAN moved out of the fixed template text into
{{data.iban_hint}}, filled only for members whose bank details are actually
listed -- it used to explain a masked IBAN to members who pay by transfer and see
no IBAN at all. It sits directly under the record as a parenthesised footnote,
which also keeps the dropped line from leaving a blank one behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:08:40 +02:00
Marcel PeterkauandClaude Opus 5 9e9bb7d668 Ask every member to check the data the club stores about them
The club has to keep its member data current, and until now that meant writing
to each member by hand. "Datenüberprüfung anfragen" in the members tab sends one
mail per member, each listing that member's 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.

Two decisions the record itself forced:

A field with no value is printed as "(nicht hinterlegt)" rather than left out.
The point of the mail is to have gaps filled in, and a missing line is a gap
nobody sees.

The IBAN is masked down to its country code and last four digits. That is enough
to recognise the account, and it keeps a full account number out of a mail the
club sends to dozens of people at once.

Every member is listed as a recipient, with the live memberships that have an
address preselected -- a member who resigned at year's end may still need to
confirm their address, so the board can add them by hand. Members without an
address are skipped and reported instead of failing the run.

Delivery reuses the existing mail machinery: the configured delivery mode, one
SMTP/IMAP connection for the whole run, an archive copy in the member file, a
"data_review_email_sent" event, and the read-only guard before anything is
rendered. A member whose mail fails is reported as a warning and the run
continues -- one bad address must not stop a mailing to the whole club halfway
through. Because the run cannot be taken back, the recipient count is confirmed
once more before it starts.

Subject and text come from a new "Datenüberprüfung" template, editable like the
others, with {{data.sheet}} for the whole record and a {{#data}} block for a
layout of the board's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:05:38 +02:00
Marcel PeterkauandClaude Opus 5 0252a0c0e3 Wait for the options window before grabbing it
OptionsDialog called grab_set() straight after transient(), before the window
was on screen. Tk refuses a grab on a window that is not viewable yet, and this
dialog builds enough tabs that it regularly still is not -- the options UI tests
failed with "grab failed: window not viewable" once the mail-template tab grew
another entry.

The grab now waits for an idle callback, which is what the other dialogs in the
codebase already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:05:26 +02:00
Git-CI e4138e93b6 ci: bump dev version to 0.1.0-dev18 [skip ci] 2026-08-29 01:24:56 +02:00
Marcel Peterkau 071167b626 Merge pull request 'Keep CCMA usable when the member store is mounted read-only' (#24) from feature/read-only-store into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/24
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-28 23:23:14 +00:00
Marcel PeterkauandClaude Opus 5 e87f859101 Refuse the welcome mail on a read-only store before it renders anything
generate_and_send_welcome_mail() arrived with the mail templates, after the
read-only guards were added to the other services, and never got one. On a
read-only store it therefore rendered the mail, could hand it to the mail
server, and only failed when it tried to create the archive directory in the
member file -- surfacing a PermissionError instead of the ReadOnlyStoreError
every other write path reports.

The guard now sits at the top, next to the delivery-mode check, so nothing is
rendered, sent or written. The read-only test covers this path (and the SEPA
batch alongside it) and asserts that nothing at all was left behind: no export
file, no archive directory, no "sent" event.

Its member carries an e-mail address now -- without one the mail services bail
out for that reason, and the write the test exists for is never reached.

Note that the SEPA CSV/XML export keeps writing without a guard on purpose: it
writes to a path the board picks outside the store, which a read-only store has
no say over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:09:05 +02:00
Marcel Peterkau 6a841c9bdd Merge branch 'dev' into feature/read-only-store 2026-08-28 23:05:26 +00:00
Marcel PeterkauandClaude Opus 5 528c24ad42 Clear every read-only marker when the store becomes writable again
"Erneut prüfen" refreshed the banner but left the window title and the status bar
reading "NUR LESEN" -- both were built once and never updated -- while the banner
itself asked for a restart that the recheck exists to avoid.

Title, status bar and banner are refreshed from one place now, so none of them can
be left behind, and the main window owns the title instead of app.py setting it
once at startup. The banner points at "Erneut prüfen" and says outright that no
restart is needed. Since the startup pass was skipped, its task list is empty
rather than current, so a successful recheck offers the housekeeper run that
fills it.

UI tests cover both directions of the recheck; their Tk root moved into a shared
conftest fixture, because a second root in another module invalidates the icon
images bound to the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:01:37 +02:00
Marcel PeterkauandClaude Opus 5 ae53c168fd Merge branch 'dev' into feature/read-only-store
dev gained the editable mail templates, which live in the store like everything
else -- so they follow the same read-only rules: the options dialog skips saving
them, save_mail_template() reports the store instead of a raw PermissionError,
and a store that cannot keep its own copy simply renders from the shipped
default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:26:40 +02:00
Marcel PeterkauandClaude Opus 5 be042949a2 Keep CCMA usable when the member store is mounted read-only
The encrypted volume holding the member data can be mounted without write
access, but starting against such a store failed: the housekeeper takes a lock
file before doing anything, so its startup pass died with a PermissionError and
took the whole start with it.

The store is now probed with an actual write once at startup -- permissions,
mount options and filesystem state all matter, and only an attempt covers them
together -- and a read-only store opens as a read-only session. The housekeeper
is skipped rather than attempted, every write inside the repository goes through
one guard that reports ReadOnlyStoreError (a RepositoryError, so the dialogs
already handle it) instead of letting an OS error surface, and the services that
archive into the member file check before they start sending or rendering.

The session says so permanently: a warning banner above the tabs, "NUR LESEN" in
the window title and status bar, and refused actions explaining why. Program
settings still save -- they live in the user's config directory -- while the
store-backed ones are skipped with a notice. "Erneut prüfen" picks up a volume
that was remounted writable without restarting.

A store that was never initialized still fails, but says that creating one needs
write access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:10:31 +02:00
34 changed files with 1989 additions and 161 deletions
+61 -2
View File
@@ -185,8 +185,9 @@ is removed. A loop that is not closed in the same row is rejected.
## Mail templates ## Mail templates
Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails,
and SEPA pre-notifications -- is rendered from a plain text template. The shipped 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 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 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. never overwritten; a deleted one is restored from the shipped default.
@@ -207,6 +208,55 @@ rejects unknown ones when saving. A line that contains nothing but placeholders
which render empty (an optional hint, an empty list) is dropped, and which render empty (an optional hint, an empty list) is dropped, and
`{{#claims}} ... {{/claims}}` repeats its content once per claim. `{{#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 ## Housekeeper rules
The housekeeper runs every rule for every member. Built-in Python rules live in The housekeeper runs every rule for every member. Built-in Python rules live in
@@ -241,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 optional fee line item. A claim-level dunning hold suppresses automatic and
manual reminder preparation until it is removed or expires. 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. Do not place a real member store inside the source repository.
+1 -1
View File
@@ -1 +1 @@
0.1.0-dev17 0.2.0
+4 -1
View File
@@ -40,7 +40,10 @@
"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.", "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.", "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.", "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." "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}}
+16
View File
@@ -160,6 +160,22 @@ def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
) )
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: def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
valid_from = str(override.get("valid_from", "")) valid_from = str(override.get("valid_from", ""))
valid_until = str(override.get("valid_until", "") or "") valid_until = str(override.get("valid_until", "") or "")
+50
View File
@@ -97,6 +97,21 @@ BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("signature", "Signatur aus dem Versandfenster"), ("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_ITEM_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("claim.title", "Bezeichnung der Forderung"), ("claim.title", "Bezeichnung der Forderung"),
("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"), ("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"),
@@ -186,6 +201,41 @@ MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
("debit.mandate_reference", "Mandatsreferenz"), ("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}}",
),
),
),
) )
+12 -57
View File
@@ -1,6 +1,6 @@
from datetime import date, timedelta 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 from ccma.rules.api import RuleAction, RuleContext, task
RULE_ID = "reminder-due" RULE_ID = "reminder-due"
@@ -18,12 +18,12 @@ DEFAULT_POLICY = {
def evaluate(context: RuleContext) -> list[RuleAction]: def evaluate(context: RuleContext) -> list[RuleAction]:
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the """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 grace period, then either the postal dunning escalation or -- for a claim the
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct member's mandate still covers, where sending a dunning letter makes no sense -- a
"check the direct debit" notice. Falls back to the plain overdue notice whenever no distinct "check the direct debit" notice. Falls back to the plain overdue notice
escalation applies (dunning hold active, or between reminder levels), so there is whenever no escalation applies (dunning hold active, or between reminder levels),
always exactly one finding for an overdue claim, never both an overdue notice and a so there is always exactly one finding for an overdue claim, never both an overdue
reminder notice at once.""" notice and a reminder notice at once."""
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0))) levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
grace_days = int(policy.get("grace_days_after_due", 7)) grace_days = int(policy.get("grace_days_after_due", 7))
@@ -48,8 +48,11 @@ def evaluate(context: RuleContext) -> list[RuleAction]:
escalation = None escalation = None
if days_overdue >= grace_days and not _hold_is_active(claim, context.today): 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 --
escalation = _sepa_escalation(context, claim, claim_id, days_overdue) # 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: else:
escalation = _reminder_action(context, claim, claim_id, levels, policy) escalation = _reminder_action(context, claim, claim_id, levels, policy)
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue)) actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
@@ -72,13 +75,6 @@ def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_
) )
def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
if pending:
return _sepa_awaiting_action(context, claim, claim_id, *pending)
return _sepa_action(context, claim, claim_id, days_overdue)
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction: def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
balance = money_text(claim_balance(context.contributions, claim)) balance = money_text(claim_balance(context.contributions, claim))
return task( return task(
@@ -97,47 +93,6 @@ def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int)
) )
def _sepa_awaiting_action(
context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
) -> RuleAction:
"""A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
claim and its own deadline hasn't expired yet -- staff already engaged, so this
must not keep nagging "check the direct debit" as if nothing had happened."""
name = str(reminder.get("name") or "Rücklastschrift")
balance = money_text(claim_balance(context.contributions, claim))
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:sepa-awaiting",
severity="info",
code="sepa_debit_awaiting_deadline",
title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
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 _latest_pending_reminder(
reminders: list[dict], claim_id: str, today: date
) -> tuple[dict, date] | None:
best: tuple[dict, date] | None = None
for item in reminders:
if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
continue
try:
deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
except ValueError:
continue
if today > deadline:
continue
if best is None or deadline > best[1]:
best = (item, deadline)
return best
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None: def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
reminders = [ reminders = [
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
+1
View File
@@ -99,6 +99,7 @@ class DocumentService:
if not template_path.is_file() or template_path.suffix.casefold() not in SUPPORTED_TEMPLATE_SUFFIXES: 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.") raise DocumentError("Das ausgewählte OpenDocument-Template ist nicht verfügbar.")
self.repository.assert_writable()
member = self.repository.get_member(member_id) member = self.repository.get_member(member_id)
data = None data = None
claim = None claim = None
+4
View File
@@ -77,6 +77,9 @@ class Housekeeper:
overrides the configured setting for just this run without changing it overrides the configured setting for just this run without changing it
permanently -- e.g. to recompute one member's full claim history on demand permanently -- e.g. to recompute one member's full claim history on demand
after a contribution change, without retroactively billing everyone else.""" 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() current_date = today or date.today()
delay = _non_negative_delay(member_delay) delay = _non_negative_delay(member_delay)
effective_settings = ( effective_settings = (
@@ -189,6 +192,7 @@ class Housekeeper:
return _open_findings(working["items"]) return _open_findings(working["items"])
def delete_task(self, key: str) -> list[HousekeeperFinding]: def delete_task(self, key: str) -> list[HousekeeperFinding]:
self.repository.assert_writable()
selected_key = key.strip() selected_key = key.strip()
if not selected_key: if not selected_key:
raise RepositoryError("Der Task hat keinen gültigen Key.") raise RepositoryError("Der Task hat keinen gültigen Key.")
+5 -2
View File
@@ -38,11 +38,14 @@ def payment_instructions(
*, *,
due_date: str, due_date: str,
reference: str, reference: str,
expect_transfer: bool = False,
) -> str: ) -> str:
"""Ready-made payment paragraph: members with an active mandate are told the """Ready-made payment paragraph: members with an active mandate are told the
money is collected, everyone else gets the club's bank details.""" 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 {} organization = organization if isinstance(organization, dict) else {}
if member.mandate_active: if member.mandate_active and not expect_transfer:
mandate = member.mandate_reference.strip() mandate = member.mandate_reference.strip()
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else "" mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
return ( return (
+349
View File
@@ -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
+10 -1
View File
@@ -96,8 +96,16 @@ def reminder_mail_bytes(
), ),
"reminder.fee_total": money_text(reminder_items_total(reminder)), "reminder.fee_total": money_text(reminder_items_total(reminder)),
"payment.reference": reference, "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( "payment.instructions": payment_instructions(
member, organization, due_date=payment_deadline, reference=reference member,
organization,
due_date=payment_deadline,
reference=reference,
expect_transfer=True,
), ),
} }
) )
@@ -139,6 +147,7 @@ def generate_and_send_reminder_mail(
) -> GeneratedReminderMail: ) -> GeneratedReminderMail:
if delivery_mode not in {"local", "send", "drafts"}: if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}") raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
repository.assert_writable()
member = repository.get_member(member_id) member = repository.get_member(member_id)
data, claim = repository.get_claim(member_id, claim_id) data, claim = repository.get_claim(member_id, claim_id)
reminder = repository._find_reminder(data, claim_id, reminder_id) reminder = repository._find_reminder(data, claim_id, reminder_id)
+20 -6
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 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 from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08" 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(): for member in repository.list_members():
if not member.mandate_active: if not member.mandate_active:
continue continue
member_label = member.member_number or member.display_name
missing = [ missing = [
label label
for value, label in ( for value, label in (
@@ -73,13 +74,13 @@ def pending_direct_debits(
if not str(value).strip() if not str(value).strip()
] ]
if missing: if missing:
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.") warnings.append(f"{member_label}: {', '.join(missing)} fehlt.")
continue continue
try: try:
validate_iban(member.iban) validate_iban(member.iban)
date.fromisoformat(member.mandate_signed_at) date.fromisoformat(member.mandate_signed_at)
except (RepositoryError, ValueError) as exc: except (RepositoryError, ValueError) as exc:
warnings.append(f"{member.member_number or member.display_name}: {exc}") warnings.append(f"{member_label}: {exc}")
continue continue
data = repository.get_contributions(member.member_id) data = repository.get_contributions(member.member_id)
selected = [] selected = []
@@ -92,9 +93,22 @@ def pending_direct_debits(
except ValueError: except ValueError:
continue continue
balance = claim_balance(data, claim) balance = claim_balance(data, claim)
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0: if not ((due_from is None or claim_due >= due_from) and claim_due <= due_until):
selected.append(claim) continue
amount += balance 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: if not selected:
continue continue
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected] titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
+1
View File
@@ -88,6 +88,7 @@ def generate_debit_mails(
sender_email: str, sender_email: str,
signature: str, signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]: ) -> tuple[list[GeneratedDebitMail], list[str]]:
repository.assert_writable()
output: Path | None = None output: Path | None = None
email_settings: dict | None = None email_settings: dict | None = None
smtp_ctx = nullcontext(None) smtp_ctx = nullcontext(None)
+3
View File
@@ -141,6 +141,9 @@ def generate_and_send_welcome_mail(
) -> GeneratedWelcomeMail: ) -> GeneratedWelcomeMail:
if delivery_mode not in {"local", "send", "drafts"}: if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}") 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) member = repository.get_member(member_id)
if not member.email.strip(): if not member.email.strip():
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.") raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
+83 -18
View File
@@ -68,6 +68,12 @@ class RepositoryError(RuntimeError):
pass 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_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
DEFAULT_CONTRIBUTION_RULES = [ DEFAULT_CONTRIBUTION_RULES = [
@@ -182,8 +188,60 @@ class MemberRepository:
self.root = Path(root).expanduser().resolve() self.root = Path(root).expanduser().resolve()
self.members_root = self.root / "members" self.members_root = self.root / "members"
self.assets_root = self.root / "assets" 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: 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.members_root.mkdir(parents=True, exist_ok=True)
self.assets_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) (self.root / "rules").mkdir(parents=True, exist_ok=True)
@@ -212,7 +270,7 @@ class MemberRepository:
continue continue
config_path = self.root / "repository.json" config_path = self.root / "repository.json"
if not config_path.exists(): if not config_path.exists():
write_json_atomic(config_path, DEFAULT_CONFIGURATION) self._write_json(config_path, DEFAULT_CONFIGURATION)
def validate(self) -> list[str]: def validate(self) -> list[str]:
errors: list[str] = [] errors: list[str] = []
@@ -403,6 +461,7 @@ class MemberRepository:
selected_number = self._allocate_member_number( selected_number = self._allocate_member_number(
policy["pattern"], policy["allocation_strategy"] policy["pattern"], policy["allocation_strategy"]
) )
self.assert_writable()
member_id = str(uuid4()) member_id = str(uuid4())
directory = self._member_path(member_id) directory = self._member_path(member_id)
directory.mkdir(parents=True, exist_ok=False) directory.mkdir(parents=True, exist_ok=False)
@@ -418,8 +477,8 @@ class MemberRepository:
birth_date=birth_date, birth_date=birth_date,
application_date=application_date, application_date=application_date,
) )
write_json_atomic(directory / "member.json", member.to_dict()) self._write_json(directory / "member.json", member.to_dict())
write_json_atomic(directory / "contributions.json", ContributionData().to_dict()) self._write_json(directory / "contributions.json", ContributionData().to_dict())
self.append_event( self.append_event(
member_id, member_id,
event_type="member_created", event_type="member_created",
@@ -467,7 +526,7 @@ class MemberRepository:
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id) self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
changes = self._summarize_changes(existing, member) changes = self._summarize_changes(existing, member)
member.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") 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: if changes:
self.append_event( self.append_event(
member.member_id, member.member_id,
@@ -528,6 +587,7 @@ class MemberRepository:
if deposit_amount < 0: if deposit_amount < 0:
raise RepositoryError("Die Kaution darf nicht negativ sein.") raise RepositoryError("Die Kaution darf nicht negativ sein.")
self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "") self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "")
self.assert_writable()
asset_id = str(uuid4()) asset_id = str(uuid4())
directory = self._asset_path(asset_id) directory = self._asset_path(asset_id)
directory.mkdir(parents=True, exist_ok=False) directory.mkdir(parents=True, exist_ok=False)
@@ -549,7 +609,7 @@ class MemberRepository:
condition=condition.strip(), condition=condition.strip(),
estimated_value=estimated_value.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( self.append_asset_event(
asset.asset_id, asset.asset_id,
event_type="asset_created", event_type="asset_created",
@@ -612,7 +672,7 @@ class MemberRepository:
raise RepositoryError("Status issued benötigt ein zugeordnetes Mitglied.") raise RepositoryError("Status issued benötigt ein zugeordnetes Mitglied.")
changes = self._summarize_asset_changes(existing, asset) changes = self._summarize_asset_changes(existing, asset)
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") 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: if changes:
self.append_asset_event( self.append_asset_event(
asset.asset_id, asset.asset_id,
@@ -652,7 +712,7 @@ class MemberRepository:
asset.custody_type = "member" asset.custody_type = "member"
asset.status = "issued" asset.status = "issued"
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") 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( self.append_asset_event(
asset.asset_id, asset.asset_id,
event_type="asset_issued", event_type="asset_issued",
@@ -680,7 +740,7 @@ class MemberRepository:
asset.custody_type = "club" asset.custody_type = "club"
asset.status = "available" asset.status = "available"
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") 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( self.append_asset_event(
asset.asset_id, asset.asset_id,
event_type="asset_returned", event_type="asset_returned",
@@ -839,6 +899,7 @@ class MemberRepository:
references=references or {}, references=references or {},
data=data or {}, data=data or {},
) )
self.assert_writable()
path = directory / "events.jsonl" path = directory / "events.jsonl"
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n" line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
with path.open("a", encoding="utf-8", newline="\n") as handle: with path.open("a", encoding="utf-8", newline="\n") as handle:
@@ -894,7 +955,7 @@ class MemberRepository:
def save_contributions(self, member_id: str, data: ContributionData) -> None: def save_contributions(self, member_id: str, data: ContributionData) -> None:
self.get_member(member_id) 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]: def get_claim(self, member_id: str, claim_id: str) -> tuple[ContributionData, dict]:
data = self.get_contributions(member_id) data = self.get_contributions(member_id)
@@ -2417,6 +2478,7 @@ class MemberRepository:
references=references or {}, references=references or {},
data=data or {}, data=data or {},
) )
self.assert_writable()
path = directory / "events.jsonl" path = directory / "events.jsonl"
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n" line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
with path.open("a", encoding="utf-8", newline="\n") as handle: with path.open("a", encoding="utf-8", newline="\n") as handle:
@@ -2523,12 +2585,12 @@ class MemberRepository:
def refresh_member_record_hashes(self, member_id: str) -> None: def refresh_member_record_hashes(self, member_id: str) -> None:
member = self.get_member(member_id) member = self.get_member(member_id)
contributions = self.get_contributions(member_id) contributions = self.get_contributions(member_id)
write_json_atomic(self._member_path(member_id) / "member.json", member.to_dict()) self._write_json(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) / "contributions.json", contributions.to_dict())
def refresh_asset_record_hashes(self, asset_id: str) -> None: def refresh_asset_record_hashes(self, asset_id: str) -> None:
asset = self.get_asset(asset_id) 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]: def get_member_number_policy(self) -> dict[str, str]:
try: try:
@@ -2565,7 +2627,7 @@ class MemberRepository:
"allocation_strategy": allocation_strategy, "allocation_strategy": allocation_strategy,
} }
config.setdefault("member_number_sequences", {}) 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: def get_reminder_policy(self) -> dict:
config = self.get_configuration() config = self.get_configuration()
@@ -2653,7 +2715,7 @@ class MemberRepository:
"levels": normalized_levels, "levels": normalized_levels,
"standard_fee_items": normalized_items, "standard_fee_items": normalized_items,
} }
write_json_atomic(self.root / "repository.json", config) self._write_json(self.root / "repository.json", config)
def get_email_settings(self) -> dict: def get_email_settings(self) -> dict:
config = self.get_configuration() config = self.get_configuration()
@@ -2747,7 +2809,7 @@ class MemberRepository:
"imap_sent_enabled": bool(imap_sent_enabled), "imap_sent_enabled": bool(imap_sent_enabled),
"imap_sent_folder": imap_sent_folder.strip() or "INBOX.Sent", "imap_sent_folder": imap_sent_folder.strip() or "INBOX.Sent",
} }
write_json_atomic(self.root / "repository.json", config) self._write_json(self.root / "repository.json", config)
@property @property
def mail_templates_root(self) -> Path: def mail_templates_root(self) -> Path:
@@ -2756,7 +2818,8 @@ class MemberRepository:
def get_mail_template(self, key: str) -> MailTemplate: def get_mail_template(self, key: str) -> MailTemplate:
"""Reads the store's copy of a mail template. Stores created before mail """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 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.""" 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) spec = template_spec(key)
path = self.mail_templates_root / spec.filename path = self.mail_templates_root / spec.filename
try: try:
@@ -2771,6 +2834,7 @@ class MemberRepository:
return template return template
def save_mail_template(self, key: str, *, subject: str, body: str) -> None: def save_mail_template(self, key: str, *, subject: str, body: str) -> None:
self.assert_writable()
spec = template_spec(key) spec = template_spec(key)
try: try:
validate_mail_template(key, subject, body) validate_mail_template(key, subject, body)
@@ -2795,7 +2859,7 @@ class MemberRepository:
raise RepositoryError("Der Vereinsname ist erforderlich.") raise RepositoryError("Der Vereinsname ist erforderlich.")
config = self.get_configuration() config = self.get_configuration()
config["organization"] = organization config["organization"] = organization
write_json_atomic(self.root / "repository.json", config) self._write_json(self.root / "repository.json", config)
def preview_member_number( def preview_member_number(
self, pattern: str | None = None, allocation_strategy: str | None = None self, pattern: str | None = None, allocation_strategy: str | None = None
@@ -2841,7 +2905,7 @@ class MemberRepository:
sequences = {} sequences = {}
config["member_number_sequences"] = sequences config["member_number_sequences"] = sequences
sequences[pattern] = next_value sequences[pattern] = next_value
write_json_atomic(self.root / "repository.json", config) self._write_json(self.root / "repository.json", config)
return member_number return member_number
def _next_available_member_number( def _next_available_member_number(
@@ -2867,6 +2931,7 @@ class MemberRepository:
@contextmanager @contextmanager
def _member_number_lock(self): def _member_number_lock(self):
self.assert_writable()
lock_path = self.root / ".member-number.lock" lock_path = self.root / ".member-number.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle: with lock_path.open("a+", encoding="utf-8") as handle:
+5 -1
View File
@@ -404,7 +404,11 @@ class AssetTab(ttk.Frame):
self._confirm_integrity_and_then(self._refresh_hashes_only) self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None: 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.refresh()
self.on_changed() self.on_changed()
+278
View File
@@ -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()
+90 -9
View File
@@ -10,9 +10,11 @@ from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.asset_tab import AssetTab from ccma.ui.asset_tab import AssetTab
from ccma.ui.claim_tab import ClaimTab 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.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog
from ccma.ui.icons import IconStore from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab 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.options_dialog import OptionsDialog
from ccma.ui.sepa_dialog import SepaExportDialog from ccma.ui.sepa_dialog import SepaExportDialog
from ccma.ui.theme import load_theme from ccma.ui.theme import load_theme
@@ -98,10 +100,14 @@ class MainWindow(ttk.Frame):
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=1)
self._build_ribbon() 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 = 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.tabs = TabManager(self.notebook)
self.dashboard = DashboardTab( self.dashboard = DashboardTab(
self.notebook, self.notebook,
@@ -117,16 +123,75 @@ class MainWindow(ttk.Frame):
icon_name="view-dashboard", icon_name="view-dashboard",
) )
status = ttk.Frame(self, padding=(6, 5)) 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) status.columnconfigure(0, weight=1)
ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid( ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid(
row=0, column=0, sticky="w" row=0, column=0, sticky="w"
) )
ttk.Label( self.store_var = tk.StringVar()
status, ttk.Label(status, textvariable=self.store_var, style="Status.TLabel").grid(
text=f"STORE {self.repository.root} · VERSION {__version__}", row=0, column=1, sticky="e"
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: def _build_ribbon(self) -> None:
ribbon = ttk.Frame(self, style="Ribbon.TFrame") ribbon = ttk.Frame(self, style="Ribbon.TFrame")
@@ -381,6 +446,7 @@ class MainWindow(ttk.Frame):
self.findings, self.findings,
self.open_member, self.open_member,
lambda: self.tabs.close(key), lambda: self.tabs.close(key),
self.open_data_review_mail,
) )
self.tabs.add( self.tabs.add(
key, key,
@@ -416,10 +482,19 @@ class MainWindow(ttk.Frame):
def open_sepa_export(self) -> None: def open_sepa_export(self) -> None:
SepaExportDialog(self, self.repository) 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: def new_member(self) -> None:
if self._refuse_read_only("Das Anlegen eines Mitglieds"):
return
NewMemberDialog(self, self.repository, self._member_created) NewMemberDialog(self, self.repository, self._member_created)
def new_asset(self) -> None: def new_asset(self) -> None:
if self._refuse_read_only("Das Anlegen eines Assets"):
return
NewAssetDialog(self, self.repository, self._asset_created) NewAssetDialog(self, self.repository, self._asset_created)
def _member_created(self, member: Member) -> None: def _member_created(self, member: Member) -> None:
@@ -499,6 +574,8 @@ class MainWindow(ttk.Frame):
) )
def run_housekeeper(self) -> list[HousekeeperFinding]: 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.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
self.refresh_overview(run_housekeeper=False) self.refresh_overview(run_housekeeper=False)
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.") self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
@@ -507,6 +584,8 @@ class MainWindow(ttk.Frame):
def run_housekeeper_for_member( def run_housekeeper_for_member(
self, member_id: str, retroactive_claims: bool = False self, member_id: str, retroactive_claims: bool = False
) -> list[HousekeeperFinding]: ) -> list[HousekeeperFinding]:
if self._refuse_read_only("Ein Hausmeisterlauf"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run( self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
member_id=member_id, retroactive_claims=retroactive_claims or None member_id=member_id, retroactive_claims=retroactive_claims or None
) )
@@ -515,13 +594,15 @@ class MainWindow(ttk.Frame):
return self.findings return self.findings
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]: 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.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
self.refresh_overview(run_housekeeper=False) self.refresh_overview(run_housekeeper=False)
self.status_var.set("Hausmeister-Task gelöscht.") self.status_var.set("Hausmeister-Task gelöscht.")
return self.findings return self.findings
def refresh_overview(self, *, run_housekeeper: bool = True) -> None: 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.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
self.dashboard.update_data(self.repository.member_count(), self.findings) self.dashboard.update_data(self.repository.member_count(), self.findings)
members_tab = self.tabs.tabs.get("members") members_tab = self.tabs.tabs.get("members")
+16 -8
View File
@@ -1130,7 +1130,11 @@ class MemberTab(ttk.Frame):
self._confirm_integrity_and_then(self._refresh_hashes_only) self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None: 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.refresh()
self.on_changed() self.on_changed()
@@ -1150,13 +1154,17 @@ class MemberTab(ttk.Frame):
text = self.comment_var.get().strip() text = self.comment_var.get().strip()
if not text: if not text:
return return
self.repository.append_event( try:
self.member_id, self.repository.append_event(
event_type="board_comment", self.member_id,
summary=text, event_type="board_comment",
actor_type="user", summary=text,
actor_name="Vorstand", 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.comment_var.set("")
self._refresh_events() self._refresh_events()
+33 -16
View File
@@ -86,7 +86,10 @@ class OptionsDialog(tk.Toplevel):
self.mail_template_snippets: dict[str, str] = {} self.mail_template_snippets: dict[str, str] = {}
self.title("Optionen") self.title("Optionen")
self.transient(master.winfo_toplevel()) 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.resizable(True, True)
self._build_ui() self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy()) self.bind("<Escape>", lambda _event: self.destroy())
@@ -911,26 +914,40 @@ class OptionsDialog(tk.Toplevel):
self.config_obj.optional_member_fields = tuple( self.config_obj.optional_member_fields = tuple(
field for field, variable in self.optional_member_field_vars.items() if variable.get() 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: try:
self.config_obj.save() self.config_obj.save()
self.repository.save_member_number_policy( if not store_read_only:
mode=number_mode, self.repository.save_member_number_policy(
pattern=number_pattern, mode=number_mode,
allocation_strategy=self.number_strategy_var.get(), 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_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, self.repository.save_reminder_policy(
levels=self.levels, grace_days_after_due=grace_days_after_due,
standard_fee_items=self.standard_items, levels=self.levels,
) standard_fee_items=self.standard_items,
self.repository.save_email_settings(**email_settings) )
self._save_mail_templates() self.repository.save_email_settings(**email_settings)
self._save_mail_templates()
except (OSError, RepositoryError) as exc: except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self) messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return 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: if self.on_saved:
self.on_saved(store_changed) self.on_saved(store_changed)
self.destroy() self.destroy()
+4 -2
View File
@@ -154,7 +154,9 @@ class SepaExportDialog(tk.Toplevel):
) )
self._update_summary() self._update_summary()
self.warning_var.set( 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: def _selected_debits(self) -> list:
@@ -185,7 +187,7 @@ class SepaExportDialog(tk.Toplevel):
) )
return None return None
if self.warnings and not messagebox.askyesno( if self.warnings and not messagebox.askyesno(
"Unvollständige Mandate", "Übersprungene Positionen",
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?", "\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
parent=self, parent=self,
): ):
+8 -1
View File
@@ -121,7 +121,14 @@ class SplashScreen(tk.Toplevel):
self.repository.list_members() self.repository.list_members()
member_count = len(self.repository.list_member_ids()) member_count = len(self.repository.list_member_ids())
findings = [] 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 …")) self._messages.put(("status", "Starte Hausmeister …"))
findings = Housekeeper(self.repository, self.housekeeper_settings).run( findings = Housekeeper(self.repository, self.housekeeper_settings).run(
member_delay=_member_delay_for_splash( member_delay=_member_delay_for_splash(
+11 -1
View File
@@ -245,12 +245,14 @@ class MembersTab(ttk.Frame):
findings: list[HousekeeperFinding], findings: list[HousekeeperFinding],
on_open: Callable[[str], None], on_open: Callable[[str], None],
on_close: Callable[[], None], on_close: Callable[[], None],
on_data_review: Callable[[], None] | None = None,
): ):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.members = members self.members = members
self.findings = findings self.findings = findings
self.on_open = on_open self.on_open = on_open
self.on_close = on_close self.on_close = on_close
self.on_data_review = on_data_review
self._build_ui() self._build_ui()
def _build_ui(self) -> None: 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") ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar() self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") 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 = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters) filter_row = ttk.Frame(filters)
+20
View File
@@ -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()
+72
View File
@@ -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()
+122
View File
@@ -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."
+297
View File
@@ -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",
)
+1 -17
View File
@@ -3,7 +3,7 @@ from email.policy import default
import pytest import pytest
tk = pytest.importorskip("tkinter") pytest.importorskip("tkinter")
from ccma.config import AppConfig # noqa: E402 from ccma.config import AppConfig # noqa: E402
from ccma.domain.mail_templates import placeholder_entries # noqa: E402 from ccma.domain.mail_templates import placeholder_entries # noqa: E402
@@ -11,22 +11,6 @@ from ccma.services.welcome_mail import generate_and_send_welcome_mail # noqa: E
from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402 from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402
# One root for the whole session: the icon library binds its images to the first
# Tk instance, so tearing a root down between tests would invalidate them.
@pytest.fixture(scope="session")
def tk_root():
from ccma.ui.theme import load_theme
try:
root = tk.Tk()
except tk.TclError as exc: # headless CI has no display to build widgets on
pytest.skip(f"kein Display verfügbar: {exc}")
root.withdraw()
load_theme(root, "dark")
yield root
root.destroy()
@pytest.fixture @pytest.fixture
def repository(tmp_path): def repository(tmp_path):
repository = MemberRepository(tmp_path / "store") repository = MemberRepository(tmp_path / "store")
+253
View File
@@ -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.")
+53
View File
@@ -58,6 +58,59 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
assert generated.archive_path.parent.name == "Mahnungen" 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): def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store") repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org" member.email = "reminder@example.org"
+46 -18
View File
@@ -32,6 +32,15 @@ def _overdue_claim_repository(tmp_path):
return repository, member 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: def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path) repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository) housekeeper = Housekeeper(repository)
@@ -136,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: def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path) repository, member = _overdue_claim_repository(tmp_path)
member.iban = "DE89370400440532013000" _sepa_member(repository, member)
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
housekeeper = Housekeeper(repository) housekeeper = Housekeeper(repository)
findings = housekeeper.run(today=date(2026, 2, 10)) findings = housekeeper.run(today=date(2026, 2, 10))
@@ -152,13 +157,12 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
assert "SEPA" in sepa_finding.detail assert "SEPA" in sepa_finding.detail
def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_path) -> None: 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) repository, member = _overdue_claim_repository(tmp_path)
member.iban = "DE89370400440532013000" _sepa_member(repository, member)
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
housekeeper = Housekeeper(repository) housekeeper = Housekeeper(repository)
draft = repository.create_reminder_draft( draft = repository.create_reminder_draft(
@@ -177,20 +181,44 @@ def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_p
for item in housekeeper.run(today=deadline - timedelta(days=1)) for item in housekeeper.run(today=deadline - timedelta(days=1))
if item.member_id == member.member_id if item.member_id == member.member_id
] ]
assert not any(item.code == "sepa_debit_overdue" for item in findings) assert not any(item.code == "sepa_debit_overdue" for item in findings)
awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline") awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
assert awaiting.severity == "info" assert awaiting.severity == "info"
assert "Rücklastschrift" in awaiting.title assert "Rücklastschrift" in awaiting.title
# Once the reminder's own deadline has passed without resolution, the plain SEPA # Deadline gone by without payment: the next dunning level is due, not another
# follow-up notice returns so it doesn't just silently go quiet forever. # "der Einzug muss geprüft werden".
findings = [ findings = [
item for item in housekeeper.run(today=deadline + timedelta(days=1)) item
for item in housekeeper.run(today=deadline + timedelta(days=1))
if item.member_id == member.member_id if item.member_id == member.member_id
] ]
assert any(item.code == "sepa_debit_overdue" for item in findings) assert not any(item.code == "sepa_debit_overdue" for item in findings)
assert not any(item.code == "sepa_debit_awaiting_deadline" 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: def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
+42
View File
@@ -63,6 +63,48 @@ def test_pending_debits_select_due_open_balance(tmp_path):
assert debits[0].claim_ids == ("due",) 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): def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
repository, member = _repository(tmp_path) repository, member = _repository(tmp_path)
member.accepted_at = "2025-01-01" member.accepted_at = "2025-01-01"
+1
View File
@@ -2,6 +2,7 @@ def test_ui_modules_import_without_creating_root_window() -> None:
import ccma.app # noqa: F401 import ccma.app # noqa: F401
import ccma.ui.asset_tab # noqa: F401 import ccma.ui.asset_tab # noqa: F401
import ccma.ui.claim_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.main_window # noqa: F401
import ccma.ui.member_tab # noqa: F401 import ccma.ui.member_tab # noqa: F401
import ccma.ui.splash # noqa: F401 import ccma.ui.splash # noqa: F401