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
+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()