mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-11 21:54:18 +02:00
Compare commits
12
Commits
e4138e93b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a54647aa | ||
|
|
7aa8a2c979 | ||
|
|
cd49c04816 | ||
|
|
2409b4d123 | ||
|
|
6a1f305202 | ||
|
|
2137ff7c84 | ||
|
|
3e949566ac | ||
|
|
0abb93c021 | ||
|
|
f80b17c443 | ||
|
|
31c1646571 | ||
|
|
9e9bb7d668 | ||
|
|
0252a0c0e3 |
@@ -185,8 +185,9 @@ is removed. A loop that is not closed in the same row is rejected.
|
||||
|
||||
## Mail templates
|
||||
|
||||
Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails
|
||||
and SEPA pre-notifications -- is rendered from a plain text template. The shipped
|
||||
Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails,
|
||||
SEPA pre-notifications and the data-review request -- is rendered from a plain text
|
||||
template. The shipped
|
||||
defaults are copied into the store's `templates/mail/` directory on first start
|
||||
and can be edited there or under Optionen -> E-Mail-Vorlagen. An existing file is
|
||||
never overwritten; a deleted one is restored from the shipped default.
|
||||
@@ -207,6 +208,35 @@ rejects unknown ones when saving. A line that contains nothing but placeholders
|
||||
which render empty (an optional hint, an empty list) is dropped, and
|
||||
`{{#claims}} ... {{/claims}}` repeats its content once per claim.
|
||||
|
||||
## Data-review mailing
|
||||
|
||||
"Datenüberprüfung anfragen" in the members tab asks members to check the data the
|
||||
club stores about them. Every member appears in the recipient list; preselected are
|
||||
the live memberships (accepted, active, suspended, resigned at year's end, honorary)
|
||||
that have an e-mail address. Members without one are skipped and reported.
|
||||
|
||||
Each recipient gets one mail listing their own record -- number, name, nickname,
|
||||
birth date, contact data, address, status, member since, payment frequency, and the
|
||||
bank details only for members who have any. A field with no value is printed as
|
||||
`(nicht hinterlegt)` so the gap is visible, and the IBAN is masked down to its
|
||||
country code and last four digits, which is enough to recognise the account. The
|
||||
mail closes with the reference to put on a transfer -- member number and full name,
|
||||
either of which identifies the payment on its own.
|
||||
|
||||
The text comes from the `Datenüberprüfung` template. Besides the usual member and
|
||||
organization placeholders it offers `{{data.sheet}}` (the whole record, one field per
|
||||
line), `{{data.count}}`, `{{member.iban_masked}}`, `{{payment.reference}}` (the
|
||||
suggested transfer reference), `{{data.iban_hint}}` (the note about the shortened
|
||||
IBAN, filled only for members whose bank details are listed) and the repeat block
|
||||
`{{#data}}{{field.label}}: {{field.value}}{{/data}}` for a custom layout.
|
||||
|
||||
Delivery follows the configured e-mail mode (local `.eml` files, direct send, or
|
||||
IMAP drafts); the run is confirmed once more with the recipient count because it
|
||||
cannot be taken back. Every mail is archived below the member's
|
||||
`files/documents/Datenpruefung/` directory and logged as a `data_review_email_sent`
|
||||
event. A member whose mail fails is reported as a warning and the run continues with
|
||||
the rest.
|
||||
|
||||
## Read-only stores
|
||||
|
||||
The member store may be mounted read-only -- the encrypted volume holding the
|
||||
@@ -261,4 +291,13 @@ only confirming actual dispatch starts the new payment deadline and adds an
|
||||
optional fee line item. A claim-level dunning hold suppresses automatic and
|
||||
manual reminder preparation until it is removed or expires.
|
||||
|
||||
An overdue claim of a member with an active mandate is not dunned but reported as
|
||||
a direct debit to look into -- until a reminder for it has actually been sent. A
|
||||
sent reminder (typically the "Rücklastschrift" preset for a bounced debit) takes
|
||||
that claim off the direct-debit track for good: it is expected as a transfer by
|
||||
the stated deadline, so the housekeeper continues it in the ordinary dunning
|
||||
sequence, the SEPA run leaves it out and says so, and its dunning mail asks for a
|
||||
transfer even though the mandate is still active. Reverting the sent reminder puts
|
||||
the claim back into the direct-debit run.
|
||||
|
||||
Do not place a real member store inside the source repository.
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt.",
|
||||
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
|
||||
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
||||
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart."
|
||||
"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}}
|
||||
@@ -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:
|
||||
valid_from = str(override.get("valid_from", ""))
|
||||
valid_until = str(override.get("valid_until", "") or "")
|
||||
|
||||
@@ -97,6 +97,21 @@ BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
|
||||
("signature", "Signatur aus dem Versandfenster"),
|
||||
)
|
||||
|
||||
# The fields a member can check but which no other mail needs, so they stay out of
|
||||
# BASE_PLACEHOLDERS: the data-review mail is the one that prints the whole record.
|
||||
MEMBER_RECORD_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
|
||||
("member.birth_date", "Geburtsdatum"),
|
||||
("member.phone", "Telefonnummer"),
|
||||
("member.street", "Straße und Hausnummer"),
|
||||
("member.address_addition", "Adresszusatz"),
|
||||
("member.postal_code", "Postleitzahl"),
|
||||
("member.city", "Ort"),
|
||||
("member.country", "Land"),
|
||||
("member.account_holder", "Kontoinhaber"),
|
||||
("member.iban_masked", "IBAN, bis auf die letzten vier Stellen maskiert"),
|
||||
("member.mandate_signed_at", "Datum der Mandatserteilung"),
|
||||
)
|
||||
|
||||
CLAIM_ITEM_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
|
||||
("claim.title", "Bezeichnung der Forderung"),
|
||||
("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"),
|
||||
@@ -186,6 +201,41 @@ MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
|
||||
("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}}",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
||||
from ccma.domain.contributions import claim_balance, claim_status, claim_was_dunned, money_text
|
||||
from ccma.rules.api import RuleAction, RuleContext, task
|
||||
|
||||
RULE_ID = "reminder-due"
|
||||
@@ -18,12 +18,12 @@ DEFAULT_POLICY = {
|
||||
|
||||
def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
|
||||
grace period, then either the postal dunning escalation or -- for members paying by
|
||||
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
|
||||
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
|
||||
escalation applies (dunning hold active, or between reminder levels), so there is
|
||||
always exactly one finding for an overdue claim, never both an overdue notice and a
|
||||
reminder notice at once."""
|
||||
grace period, then either the postal dunning escalation or -- for a claim the
|
||||
member's mandate still covers, where sending a dunning letter makes no sense -- a
|
||||
distinct "check the direct debit" notice. Falls back to the plain overdue notice
|
||||
whenever no escalation applies (dunning hold active, or between reminder levels),
|
||||
so there is always exactly one finding for an overdue claim, never both an overdue
|
||||
notice and a reminder notice at once."""
|
||||
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
||||
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
||||
grace_days = int(policy.get("grace_days_after_due", 7))
|
||||
@@ -48,8 +48,11 @@ def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||
|
||||
escalation = None
|
||||
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
||||
if context.member.mandate_active:
|
||||
escalation = _sepa_escalation(context, claim, claim_id, days_overdue)
|
||||
# A claim that was already dunned is no longer collected by direct debit --
|
||||
# the member was asked to transfer it, so it continues in the ordinary
|
||||
# dunning sequence even though the mandate itself is still active.
|
||||
if context.member.mandate_active and not claim_was_dunned(context.contributions, claim):
|
||||
escalation = _sepa_action(context, claim, claim_id, days_overdue)
|
||||
else:
|
||||
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
||||
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:
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
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:
|
||||
reminders = [
|
||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
||||
|
||||
@@ -38,11 +38,14 @@ def payment_instructions(
|
||||
*,
|
||||
due_date: str,
|
||||
reference: str,
|
||||
expect_transfer: bool = False,
|
||||
) -> str:
|
||||
"""Ready-made payment paragraph: members with an active mandate are told the
|
||||
money is collected, everyone else gets the club's bank details."""
|
||||
money is collected, everyone else gets the club's bank details. `expect_transfer`
|
||||
overrides that for a claim the mandate no longer covers -- a dunned claim is
|
||||
expected as a transfer even though the member still has a mandate."""
|
||||
organization = organization if isinstance(organization, dict) else {}
|
||||
if member.mandate_active:
|
||||
if member.mandate_active and not expect_transfer:
|
||||
mandate = member.mandate_reference.strip()
|
||||
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
@@ -96,8 +96,16 @@ def reminder_mail_bytes(
|
||||
),
|
||||
"reminder.fee_total": money_text(reminder_items_total(reminder)),
|
||||
"payment.reference": reference,
|
||||
# A dunning mail only goes out because the money did not arrive, so it
|
||||
# asks for a transfer even from a member with an active mandate: this
|
||||
# claim has left the direct-debit run and telling them "we collect it,
|
||||
# you need not do anything" would contradict the letter it carries.
|
||||
"payment.instructions": payment_instructions(
|
||||
member, organization, due_date=payment_deadline, reference=reference
|
||||
member,
|
||||
organization,
|
||||
due_date=payment_deadline,
|
||||
reference=reference,
|
||||
expect_transfer=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.contributions import claim_balance, claim_was_dunned
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
||||
|
||||
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
||||
@@ -63,6 +63,7 @@ def pending_direct_debits(
|
||||
for member in repository.list_members():
|
||||
if not member.mandate_active:
|
||||
continue
|
||||
member_label = member.member_number or member.display_name
|
||||
missing = [
|
||||
label
|
||||
for value, label in (
|
||||
@@ -73,13 +74,13 @@ def pending_direct_debits(
|
||||
if not str(value).strip()
|
||||
]
|
||||
if missing:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
|
||||
warnings.append(f"{member_label}: {', '.join(missing)} fehlt.")
|
||||
continue
|
||||
try:
|
||||
validate_iban(member.iban)
|
||||
date.fromisoformat(member.mandate_signed_at)
|
||||
except (RepositoryError, ValueError) as exc:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||||
warnings.append(f"{member_label}: {exc}")
|
||||
continue
|
||||
data = repository.get_contributions(member.member_id)
|
||||
selected = []
|
||||
@@ -92,9 +93,22 @@ def pending_direct_debits(
|
||||
except ValueError:
|
||||
continue
|
||||
balance = claim_balance(data, claim)
|
||||
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
|
||||
selected.append(claim)
|
||||
amount += balance
|
||||
if not ((due_from is None or claim_due >= due_from) and claim_due <= due_until):
|
||||
continue
|
||||
if balance <= 0:
|
||||
continue
|
||||
if claim_was_dunned(data, claim):
|
||||
# The debit for this claim already bounced and the member was dunned:
|
||||
# the letter told them to transfer the money themselves, so collecting
|
||||
# it behind their back would contradict it (and can bounce again, with
|
||||
# another fee). Reverting the sent reminder puts it back in the run.
|
||||
warnings.append(
|
||||
f"{member_label}: „{claim.get('title') or 'Forderung'}“ wurde bereits "
|
||||
"gemahnt – Zahlung per Überweisung erwartet, kein erneuter Einzug."
|
||||
)
|
||||
continue
|
||||
selected.append(claim)
|
||||
amount += balance
|
||||
if not selected:
|
||||
continue
|
||||
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
|
||||
|
||||
@@ -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()
|
||||
@@ -10,6 +10,7 @@ from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.asset_tab import AssetTab
|
||||
from ccma.ui.claim_tab import ClaimTab
|
||||
from ccma.ui.data_review_mail_dialog import DataReviewMailDialog
|
||||
from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog
|
||||
from ccma.ui.icons import IconStore
|
||||
from ccma.ui.member_tab import MemberTab
|
||||
@@ -445,6 +446,7 @@ class MainWindow(ttk.Frame):
|
||||
self.findings,
|
||||
self.open_member,
|
||||
lambda: self.tabs.close(key),
|
||||
self.open_data_review_mail,
|
||||
)
|
||||
self.tabs.add(
|
||||
key,
|
||||
@@ -480,6 +482,11 @@ class MainWindow(ttk.Frame):
|
||||
def open_sepa_export(self) -> None:
|
||||
SepaExportDialog(self, self.repository)
|
||||
|
||||
def open_data_review_mail(self) -> None:
|
||||
if self._refuse_read_only("Der Versand der Datenüberprüfung"):
|
||||
return
|
||||
DataReviewMailDialog(self, self.repository)
|
||||
|
||||
def new_member(self) -> None:
|
||||
if self._refuse_read_only("Das Anlegen eines Mitglieds"):
|
||||
return
|
||||
|
||||
@@ -86,7 +86,10 @@ class OptionsDialog(tk.Toplevel):
|
||||
self.mail_template_snippets: dict[str, str] = {}
|
||||
self.title("Optionen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
# Tk refuses a grab on a window that is not on screen yet ("window not
|
||||
# viewable"), and this dialog builds enough tabs to still be unmapped here --
|
||||
# so the grab waits for the window like it does in the other dialogs.
|
||||
self.after_idle(self.grab_set)
|
||||
self.resizable(True, True)
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
|
||||
@@ -154,7 +154,9 @@ class SepaExportDialog(tk.Toplevel):
|
||||
)
|
||||
self._update_summary()
|
||||
self.warning_var.set(
|
||||
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
|
||||
f"{len(self.warnings)} Position(en) übersprungen – Details beim Export."
|
||||
if self.warnings
|
||||
else ""
|
||||
)
|
||||
|
||||
def _selected_debits(self) -> list:
|
||||
@@ -185,7 +187,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
)
|
||||
return None
|
||||
if self.warnings and not messagebox.askyesno(
|
||||
"Unvollständige Mandate",
|
||||
"Übersprungene Positionen",
|
||||
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
||||
parent=self,
|
||||
):
|
||||
|
||||
@@ -245,12 +245,14 @@ class MembersTab(ttk.Frame):
|
||||
findings: list[HousekeeperFinding],
|
||||
on_open: Callable[[str], None],
|
||||
on_close: Callable[[], None],
|
||||
on_data_review: Callable[[], None] | None = None,
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.members = members
|
||||
self.findings = findings
|
||||
self.on_open = on_open
|
||||
self.on_close = on_close
|
||||
self.on_data_review = on_data_review
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -262,7 +264,15 @@ class MembersTab(ttk.Frame):
|
||||
ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
|
||||
self.count_var = tk.StringVar()
|
||||
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
|
||||
buttons = ttk.Frame(header)
|
||||
buttons.grid(row=0, column=1, rowspan=2, sticky="e")
|
||||
if self.on_data_review is not None:
|
||||
# The mailing goes to every member, so it belongs where every member is
|
||||
# listed rather than into the already crowded ribbon.
|
||||
ttk.Button(
|
||||
buttons, text="Datenüberprüfung anfragen", command=self.on_data_review
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Tab schließen", command=self.on_close).pack(side="left")
|
||||
filters = _filter_label_frame(self)
|
||||
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||
filter_row = ttk.Frame(filters)
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -60,6 +60,23 @@ def test_the_banner_does_not_ask_for_a_restart(read_only_window):
|
||||
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -154,6 +155,16 @@ def test_documents_and_mails_refuse_before_touching_the_member_file(read_only_st
|
||||
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):
|
||||
|
||||
@@ -58,6 +58,59 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||
assert generated.archive_path.parent.name == "Mahnungen"
|
||||
|
||||
|
||||
def test_a_dunning_mail_asks_for_a_transfer_even_with_an_active_mandate(tmp_path):
|
||||
"""A member is only dunned once the money did not arrive, so this claim has left
|
||||
the direct-debit run -- "wir ziehen den Betrag ein, du musst nichts tun" would
|
||||
contradict the very letter it accompanies."""
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
member.account_holder = "Reminder Test"
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDAT-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
# The shipped template spells the bank details out; this one uses the ready-made
|
||||
# paragraph, which is what the placeholder chooser offers for a dunning mail.
|
||||
repository.save_mail_template(
|
||||
"reminder",
|
||||
subject="Mahnung: {{claim.title}}",
|
||||
body="Hallo {{member.first_name}},\n\n{{payment.instructions}}\n\n{{signature}}",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
content = BytesParser(policy=default).parsebytes(
|
||||
generated.export_path.read_bytes()
|
||||
).get_content()
|
||||
assert "Bitte überweise den Betrag" in content
|
||||
assert "DE98670505050038907751" in content
|
||||
assert "ziehen den Betrag" not in content
|
||||
|
||||
|
||||
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
|
||||
+46
-18
@@ -32,6 +32,15 @@ def _overdue_claim_repository(tmp_path):
|
||||
return repository, member
|
||||
|
||||
|
||||
def _sepa_member(repository, member):
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
return member
|
||||
|
||||
|
||||
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
@@ -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:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
_sepa_member(repository, member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
@@ -152,13 +157,12 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
||||
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)
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
_sepa_member(repository, member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
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))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline")
|
||||
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Rücklastschrift" in awaiting.title
|
||||
|
||||
# Once the reminder's own deadline has passed without resolution, the plain SEPA
|
||||
# follow-up notice returns so it doesn't just silently go quiet forever.
|
||||
# Deadline gone by without payment: the next dunning level is due, not another
|
||||
# "der Einzug muss geprüft werden".
|
||||
findings = [
|
||||
item for item in housekeeper.run(today=deadline + timedelta(days=1))
|
||||
item
|
||||
for item in housekeeper.run(today=deadline + timedelta(days=1))
|
||||
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_awaiting_deadline" for item in findings)
|
||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
||||
assert "Erste Mahnung" in reminder_task.title
|
||||
assert "Mahnstufe 2" in reminder_task.detail
|
||||
|
||||
|
||||
def test_an_undunned_sepa_claim_still_asks_to_check_the_debit(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
_sepa_member(repository, member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
findings = [
|
||||
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
# A draft alone changes nothing -- only a reminder that actually went out takes the
|
||||
# claim out of the direct-debit run.
|
||||
repository.create_reminder_draft(
|
||||
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||
)
|
||||
still_sepa = [
|
||||
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
assert {item.code for item in findings} == {"sepa_debit_overdue"}
|
||||
assert {item.code for item in still_sepa} == {"sepa_debit_overdue"}
|
||||
|
||||
|
||||
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||
|
||||
@@ -63,6 +63,48 @@ def test_pending_debits_select_due_open_balance(tmp_path):
|
||||
assert debits[0].claim_ids == ("due",)
|
||||
|
||||
|
||||
def test_a_dunned_claim_is_not_collected_again(tmp_path):
|
||||
"""The dunning letter asked the member to transfer the money -- pulling it by
|
||||
direct debit anyway would contradict it (and can bounce a second time)."""
|
||||
repository, member = _repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"due",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
# A draft alone is not a dunning: nothing has gone out yet.
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert [debit.claim_ids for debit in debits] == [("due",)]
|
||||
assert warnings == []
|
||||
|
||||
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
# Nothing else is due in the window, so the member drops out of the run entirely
|
||||
# -- and says why instead of vanishing silently.
|
||||
assert debits == []
|
||||
assert warnings == ["C3-42: „Mitgliedsbeitrag 2026“ wurde bereits gemahnt – "
|
||||
"Zahlung per Überweisung erwartet, kein erneuter Einzug."]
|
||||
|
||||
|
||||
def test_a_dunned_claim_does_not_hold_back_the_members_other_claims(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id, "due", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
|
||||
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2027, 12, 31))
|
||||
|
||||
assert [debit.claim_ids for debit in debits] == [("future",)]
|
||||
assert debits[0].amount == Decimal("150.00")
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
member.accepted_at = "2025-01-01"
|
||||
|
||||
@@ -2,6 +2,7 @@ def test_ui_modules_import_without_creating_root_window() -> None:
|
||||
import ccma.app # noqa: F401
|
||||
import ccma.ui.asset_tab # noqa: F401
|
||||
import ccma.ui.claim_tab # noqa: F401
|
||||
import ccma.ui.data_review_mail_dialog # noqa: F401
|
||||
import ccma.ui.main_window # noqa: F401
|
||||
import ccma.ui.member_tab # noqa: F401
|
||||
import ccma.ui.splash # noqa: F401
|
||||
|
||||
Reference in New Issue
Block a user