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,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
|
||||
Reference in New Issue
Block a user