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>
This commit is contained in:
Marcel Peterkau
2026-09-04 22:05:38 +02:00
co-authored by Claude Opus 5
parent 0252a0c0e3
commit 9e9bb7d668
13 changed files with 1070 additions and 4 deletions
+28 -2
View File
@@ -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,31 @@ 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 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}}`, 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
+2 -1
View File
@@ -41,7 +41,8 @@
"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."
]
},
{
@@ -0,0 +1,14 @@
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}}
Deine IBAN zeigen wir hier absichtlich nur verkürzt an die letzten vier Stellen genügen zum Abgleich.
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}}
+42
View File
@@ -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,33 @@ 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"),
),
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}}",
),
),
),
)
+316
View File
@@ -0,0 +1,316 @@
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)"
@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 any(
value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
):
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 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)),
}
)
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
+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()
+7
View File
@@ -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
+11 -1
View File
@@ -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)
+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()
+17
View File
@@ -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
+271
View File
@@ -0,0 +1,271 @@
from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
import pytest
from ccma.services.member_data_mail import (
data_review_recipients,
generate_data_review_mails,
mask_iban,
member_data_fields,
)
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 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
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",
)
+11
View File
@@ -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):
+1
View File
@@ -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