mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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:
co-authored by
Claude Opus 5
parent
0252a0c0e3
commit
9e9bb7d668
@@ -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,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",
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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