mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Send new members a welcome mail that doubles as their first invoice
Accepting a member left the board writing the "welcome, you are a member from
the 1st, and this is what it costs" mail by hand. The member file now offers it
directly: it greets the member, names the membership number and the start of the
membership, and lists every claim that is still open -- admission fee and first
contribution -- with a payment hint that adapts to whether a direct debit
mandate is active.
"Mitglied seit" rejects future dates, so a member accepted this month cannot
carry their actual start date yet; {{membership.start_date}} therefore falls
back to the 1st of the month following the acceptance decision. Individual
claims can be deselected before sending, the finished mail can be previewed,
and the result is archived in the member file like every other generated mail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d6cd58a788
commit
aa0c16347b
@@ -39,7 +39,8 @@
|
|||||||
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
|
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
|
||||||
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
|
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
|
||||||
"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.",
|
"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."
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ccma.domain.contributions import claim_balance, claim_status, claim_total, money_text
|
||||||
|
from ccma.domain.dates import format_date_for_display
|
||||||
|
from ccma.domain.models import Member
|
||||||
|
from ccma.services.mail_composition import (
|
||||||
|
compose_mail,
|
||||||
|
payment_instructions,
|
||||||
|
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
|
||||||
|
|
||||||
|
OPEN_CLAIM_STATUSES = {"open", "partially_paid", "overdue"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GeneratedWelcomeMail:
|
||||||
|
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||||
|
export_path: Path | None
|
||||||
|
archive_path: Path
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
def open_claims(repository: MemberRepository, member_id: str) -> list[dict]:
|
||||||
|
"""The claims a welcome mail can bill: everything still owed, which for a fresh
|
||||||
|
member is the admission fee plus the first contribution claim(s)."""
|
||||||
|
data = repository.get_contributions(member_id)
|
||||||
|
selected = [
|
||||||
|
claim for claim in data.claims if claim_status(data, claim) in OPEN_CLAIM_STATUSES
|
||||||
|
]
|
||||||
|
return sorted(selected, key=lambda claim: (str(claim.get("due_date", "")), str(claim.get("title", ""))))
|
||||||
|
|
||||||
|
|
||||||
|
def membership_start(member: Member, *, today: date | None = None) -> date:
|
||||||
|
"""The date the membership (and with it the first contribution) starts. A member
|
||||||
|
accepted this month usually starts on the 1st of the next one, and that date
|
||||||
|
cannot be stored yet -- "Mitglied seit" rejects future dates."""
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(member.membership_started_at))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
reference = date.fromisoformat(str(member.accepted_at))
|
||||||
|
except ValueError:
|
||||||
|
reference = today or date.today()
|
||||||
|
return date(reference.year + reference.month // 12, reference.month % 12 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def welcome_mail_bytes(
|
||||||
|
repository: MemberRepository,
|
||||||
|
*,
|
||||||
|
member: Member,
|
||||||
|
claims: list[dict],
|
||||||
|
balances: dict[str, Decimal],
|
||||||
|
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)
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"claim.title": str(claim.get("title", "")),
|
||||||
|
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
|
||||||
|
"claim.due_date": format_date_for_display(str(claim.get("due_date", ""))),
|
||||||
|
"claim.amount": money_text(claim_total(claim)),
|
||||||
|
"claim.balance": money_text(balances[str(claim.get("claim_id", ""))]),
|
||||||
|
}
|
||||||
|
for claim in claims
|
||||||
|
]
|
||||||
|
total = sum((balances[str(claim.get("claim_id", ""))] for claim in claims), Decimal("0"))
|
||||||
|
due_dates = sorted(
|
||||||
|
str(claim.get("due_date", "")) for claim in claims if str(claim.get("due_date", ""))
|
||||||
|
)
|
||||||
|
first_due_date = format_date_for_display(due_dates[0]) if due_dates else ""
|
||||||
|
reference = " – ".join(
|
||||||
|
part
|
||||||
|
for part in (
|
||||||
|
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
|
||||||
|
"Mitgliedsbeitrag",
|
||||||
|
)
|
||||||
|
if part
|
||||||
|
)
|
||||||
|
values.update(
|
||||||
|
{
|
||||||
|
"membership.start_date": format_date_for_display(
|
||||||
|
membership_start(member).isoformat()
|
||||||
|
),
|
||||||
|
"claims.list": "\n".join(
|
||||||
|
f"{entry['claim.description']} (fällig {entry['claim.due_date']}): "
|
||||||
|
f"{entry['claim.balance']} Euro"
|
||||||
|
for entry in entries
|
||||||
|
),
|
||||||
|
"claims.total": money_text(total),
|
||||||
|
"claims.count": str(len(entries)),
|
||||||
|
"claims.first_due_date": first_due_date,
|
||||||
|
"payment.reference": reference,
|
||||||
|
"payment.instructions": payment_instructions(
|
||||||
|
member, organization, due_date=first_due_date, reference=reference
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rendered = render_template(repository, "welcome", values, {"claims": 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_and_send_welcome_mail(
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
*,
|
||||||
|
claim_ids: list[str] | None = None,
|
||||||
|
delivery_mode: str,
|
||||||
|
output_path: Path | str | None = None,
|
||||||
|
sender_name: str,
|
||||||
|
sender_email: str,
|
||||||
|
signature: str,
|
||||||
|
) -> GeneratedWelcomeMail:
|
||||||
|
if delivery_mode not in {"local", "send", "drafts"}:
|
||||||
|
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||||
|
member = repository.get_member(member_id)
|
||||||
|
if not member.email.strip():
|
||||||
|
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||||
|
data = repository.get_contributions(member_id)
|
||||||
|
selected_ids = None if claim_ids is None else {str(value) for value in claim_ids}
|
||||||
|
claims = [
|
||||||
|
claim
|
||||||
|
for claim in open_claims(repository, member_id)
|
||||||
|
if selected_ids is None or str(claim.get("claim_id", "")) in selected_ids
|
||||||
|
]
|
||||||
|
if selected_ids is not None and len(claims) != len(selected_ids):
|
||||||
|
raise RepositoryError("Mindestens eine ausgewählte Forderung ist nicht mehr offen.")
|
||||||
|
balances = {
|
||||||
|
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
|
||||||
|
}
|
||||||
|
target: Path | None = None
|
||||||
|
if delivery_mode == "local":
|
||||||
|
if not output_path:
|
||||||
|
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
|
||||||
|
target = Path(output_path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||||
|
organization = repository.get_configuration().get("organization") or {}
|
||||||
|
content = welcome_mail_bytes(
|
||||||
|
repository,
|
||||||
|
member=member,
|
||||||
|
claims=claims,
|
||||||
|
balances=balances,
|
||||||
|
organization=organization,
|
||||||
|
sender_name=sender_name,
|
||||||
|
sender_email=sender_email,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
|
||||||
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
|
||||||
|
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||||
|
export_path: Path | None = None
|
||||||
|
try:
|
||||||
|
archive_temp.write_bytes(content)
|
||||||
|
if delivery_mode == "local":
|
||||||
|
export_temp = target.with_name(f".{target.name}.tmp")
|
||||||
|
try:
|
||||||
|
export_temp.write_bytes(content)
|
||||||
|
os.replace(export_temp, target)
|
||||||
|
finally:
|
||||||
|
export_temp.unlink(missing_ok=True)
|
||||||
|
export_path = target
|
||||||
|
elif delivery_mode == "send":
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
||||||
|
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||||
|
send_via_smtp(smtp_client, content)
|
||||||
|
if imap_client is not None:
|
||||||
|
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||||
|
append_message(
|
||||||
|
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
with imap_session(settings) as client:
|
||||||
|
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||||
|
append_message(
|
||||||
|
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||||
|
)
|
||||||
|
os.replace(archive_temp, archive_path)
|
||||||
|
finally:
|
||||||
|
archive_temp.unlink(missing_ok=True)
|
||||||
|
relative_path = archive_path.relative_to(
|
||||||
|
repository.members_root / member_id / "files"
|
||||||
|
).as_posix()
|
||||||
|
digest = hashlib.sha256(content).hexdigest()
|
||||||
|
repository.append_event(
|
||||||
|
member_id,
|
||||||
|
event_type="welcome_email_sent",
|
||||||
|
summary="Willkommens- und Erstrechnungsmail erzeugt",
|
||||||
|
actor_type="user",
|
||||||
|
actor_name="Vorstand",
|
||||||
|
references={"document": relative_path},
|
||||||
|
data={
|
||||||
|
"recipient": member.email,
|
||||||
|
"sha256": digest,
|
||||||
|
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
|
||||||
|
"delivery_mode": delivery_mode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return GeneratedWelcomeMail(export_path, archive_path, digest)
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
@@ -38,6 +38,7 @@ from ccma.ui.labels import display_label, storage_key
|
|||||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||||
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
||||||
from ccma.ui.scrolling import ScrollableFrame
|
from ccma.ui.scrolling import ScrollableFrame
|
||||||
|
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog
|
||||||
|
|
||||||
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
||||||
# and amount-like columns get the same fixed width, status columns get a third more
|
# and amount-like columns get the same fixed width, status columns get a third more
|
||||||
@@ -355,6 +356,13 @@ class MemberTab(ttk.Frame):
|
|||||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||||
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
||||||
|
claim_actions = ttk.Frame(contribution_tab)
|
||||||
|
claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||||
|
ttk.Button(
|
||||||
|
claim_actions,
|
||||||
|
text="Willkommens- und Erstrechnungsmail",
|
||||||
|
command=self._send_welcome_mail,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
contribution_override_tab.columnconfigure(0, weight=1)
|
contribution_override_tab.columnconfigure(0, weight=1)
|
||||||
contribution_override_tab.rowconfigure(1, weight=1)
|
contribution_override_tab.rowconfigure(1, weight=1)
|
||||||
@@ -1177,6 +1185,14 @@ class MemberTab(ttk.Frame):
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
|
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
|
||||||
|
|
||||||
|
def _send_welcome_mail(self) -> None:
|
||||||
|
WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)
|
||||||
|
|
||||||
|
def _welcome_mail_sent(self) -> None:
|
||||||
|
self._refresh_documents()
|
||||||
|
self._refresh_events()
|
||||||
|
self.on_changed()
|
||||||
|
|
||||||
def _create_document(self) -> None:
|
def _create_document(self) -> None:
|
||||||
DocumentTemplateDialog(
|
DocumentTemplateDialog(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from collections.abc import Callable
|
||||||
|
from decimal import Decimal
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import default as default_policy
|
||||||
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
|
from ccma.domain.contributions import claim_balance, money_text
|
||||||
|
from ccma.domain.dates import format_date_for_display
|
||||||
|
from ccma.services.welcome_mail import (
|
||||||
|
generate_and_send_welcome_mail,
|
||||||
|
open_claims,
|
||||||
|
welcome_mail_bytes,
|
||||||
|
)
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeMailDialog(tk.Toplevel):
|
||||||
|
"""Sends the welcome mail that doubles as the first invoice for a new member:
|
||||||
|
membership start, member number and every claim that is still open."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
on_sent: Callable[[], None],
|
||||||
|
):
|
||||||
|
super().__init__(master)
|
||||||
|
self.repository = repository
|
||||||
|
self.member_id = member_id
|
||||||
|
self.on_sent = on_sent
|
||||||
|
self.claims: list[dict] = []
|
||||||
|
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.title("Willkommens- und Erstrechnungsmail")
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.resizable(True, True)
|
||||||
|
self._build_ui()
|
||||||
|
self._refresh_claims()
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
self.columnconfigure(0, weight=1)
|
||||||
|
self.rowconfigure(2, weight=1)
|
||||||
|
ttk.Label(
|
||||||
|
self,
|
||||||
|
text=(
|
||||||
|
"Begrüßt das neue Mitglied und stellt die noch offenen Forderungen "
|
||||||
|
"(Aufnahmegebühr und erster Beitrag) als Erstrechnung zusammen. Text und "
|
||||||
|
"Betreff stammen aus der Vorlage unter Optionen → E-Mail-Vorlagen."
|
||||||
|
),
|
||||||
|
wraplength=640,
|
||||||
|
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
|
||||||
|
ttk.Label(
|
||||||
|
self,
|
||||||
|
text=(
|
||||||
|
"Standardmäßig sind alle offenen Forderungen ausgewählt – einzelne mit "
|
||||||
|
"Strg+Klick abwählen."
|
||||||
|
),
|
||||||
|
style="Muted.TLabel",
|
||||||
|
wraplength=640,
|
||||||
|
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
|
||||||
|
table = ttk.Frame(self)
|
||||||
|
table.grid(row=2, column=0, sticky="nsew", padx=14)
|
||||||
|
table.columnconfigure(0, weight=1)
|
||||||
|
table.rowconfigure(0, weight=1)
|
||||||
|
self.table = ttk.Treeview(
|
||||||
|
table,
|
||||||
|
columns=("due", "amount", "title"),
|
||||||
|
show="headings",
|
||||||
|
selectmode="extended",
|
||||||
|
height=6,
|
||||||
|
)
|
||||||
|
for key, title, width in (
|
||||||
|
("due", "Fällig", 90),
|
||||||
|
("amount", "Offen", 90),
|
||||||
|
("title", "Forderung", 320),
|
||||||
|
):
|
||||||
|
self.table.heading(key, text=title)
|
||||||
|
self.table.column(key, width=width, anchor="w", stretch=key == "title")
|
||||||
|
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())
|
||||||
|
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||||
|
row=3, column=0, sticky="w", padx=14, pady=(6, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = ttk.Frame(self)
|
||||||
|
fields.grid(row=4, column=0, sticky="ew", padx=14)
|
||||||
|
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.")
|
||||||
|
|
||||||
|
buttons = ttk.Frame(self, padding=14)
|
||||||
|
buttons.grid(row=5, column=0, sticky="e")
|
||||||
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||||
|
ttk.Button(buttons, text="Vorschau", command=self._preview).pack(side="left", padx=(8, 0))
|
||||||
|
ttk.Button(
|
||||||
|
buttons, text="E-Mail erzeugen", style="Accent.TButton", command=self._generate
|
||||||
|
).pack(side="left", padx=(8, 0))
|
||||||
|
|
||||||
|
def _refresh_claims(self) -> None:
|
||||||
|
try:
|
||||||
|
self.claims = open_claims(self.repository, self.member_id)
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Forderungen konnten nicht gelesen werden", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self.table.delete(*self.table.get_children())
|
||||||
|
for index, claim in enumerate(self.claims):
|
||||||
|
self.table.insert(
|
||||||
|
"",
|
||||||
|
"end",
|
||||||
|
iid=str(index),
|
||||||
|
values=(
|
||||||
|
format_date_for_display(str(claim.get("due_date", ""))),
|
||||||
|
money_text(claim_balance(data, claim)),
|
||||||
|
str(claim.get("description", "") or claim.get("title", "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.table.selection_set(self.table.get_children())
|
||||||
|
self._update_summary()
|
||||||
|
|
||||||
|
def _selected_claims(self) -> list[dict]:
|
||||||
|
return [self.claims[int(iid)] for iid in self.table.selection()]
|
||||||
|
|
||||||
|
def _update_summary(self) -> None:
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
selected = self._selected_claims()
|
||||||
|
total = sum((claim_balance(data, claim) for claim in selected), Decimal("0"))
|
||||||
|
self.summary_var.set(
|
||||||
|
f"{len(selected)} von {len(self.claims)} Forderungen ausgewählt – "
|
||||||
|
f"Gesamtbetrag {money_text(total)} Euro"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mail_bytes(self) -> bytes:
|
||||||
|
member = self.repository.get_member(self.member_id)
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
claims = self._selected_claims()
|
||||||
|
return welcome_mail_bytes(
|
||||||
|
self.repository,
|
||||||
|
member=member,
|
||||||
|
claims=claims,
|
||||||
|
balances={
|
||||||
|
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
|
||||||
|
},
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preview(self) -> None:
|
||||||
|
try:
|
||||||
|
message = BytesParser(policy=default_policy).parsebytes(self._mail_bytes())
|
||||||
|
except (OSError, RepositoryError) as exc:
|
||||||
|
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
window = tk.Toplevel(self)
|
||||||
|
window.title("Vorschau der Willkommensmail")
|
||||||
|
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=28)
|
||||||
|
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:
|
||||||
|
selected = self._selected_claims()
|
||||||
|
if not selected and not messagebox.askyesno(
|
||||||
|
"Keine Forderung ausgewählt",
|
||||||
|
"Es ist keine Forderung ausgewählt. Soll die Mail ohne Rechnungspositionen "
|
||||||
|
"erzeugt werden?",
|
||||||
|
parent=self,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||||
|
if delivery_mode is None:
|
||||||
|
return
|
||||||
|
output_path = None
|
||||||
|
if delivery_mode == "local":
|
||||||
|
chosen = filedialog.asksaveasfilename(
|
||||||
|
parent=self,
|
||||||
|
title="Willkommensmail als Thunderbird-Entwurf speichern",
|
||||||
|
defaultextension=".eml",
|
||||||
|
initialfile="Willkommen.eml",
|
||||||
|
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||||
|
)
|
||||||
|
if not chosen:
|
||||||
|
return
|
||||||
|
output_path = chosen
|
||||||
|
try:
|
||||||
|
result = generate_and_send_welcome_mail(
|
||||||
|
self.repository,
|
||||||
|
self.member_id,
|
||||||
|
claim_ids=[str(claim.get("claim_id", "")) for claim in selected],
|
||||||
|
delivery_mode=delivery_mode,
|
||||||
|
output_path=output_path,
|
||||||
|
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("E-Mail konnte nicht erzeugt werden", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
outcome = {
|
||||||
|
"local": f"gespeichert:\n{result.export_path}",
|
||||||
|
"send": "direkt versandt.",
|
||||||
|
"drafts": "als Entwurf im Postfach abgelegt.",
|
||||||
|
}[delivery_mode]
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Willkommensmail erzeugt",
|
||||||
|
f"Die E-Mail wurde {outcome}\n\nEine Kopie liegt in der Mitgliederakte.",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
self.destroy()
|
||||||
|
self.on_sent()
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import default
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
def _new_member_repository(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)
|
||||||
|
member = repository.create_member(
|
||||||
|
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||||
|
)
|
||||||
|
member.email = "ada@example.org"
|
||||||
|
member.accepted_at = "2026-08-20"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.create_manual_claim(
|
||||||
|
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
||||||
|
)
|
||||||
|
repository.create_manual_claim(
|
||||||
|
member.member_id,
|
||||||
|
title="Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||||
|
amount="60.00",
|
||||||
|
due_date="2026-09-17",
|
||||||
|
)
|
||||||
|
return repository, member
|
||||||
|
|
||||||
|
|
||||||
|
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
export_path = tmp_path / "Willkommen.eml"
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=export_path,
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||||
|
content = message.get_content()
|
||||||
|
assert message["To"] == "ada@example.org"
|
||||||
|
assert "Willkommen" in message["Subject"]
|
||||||
|
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||||
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
|
||||||
|
assert "Gesamtbetrag: 75.00 Euro" in content
|
||||||
|
assert "DE98670505050038907751" in content
|
||||||
|
assert content.rstrip().endswith("Der Vorstand")
|
||||||
|
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
|
||||||
|
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_welcome_mail_is_archived_and_logged(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated.archive_path.parent.name == "Willkommen"
|
||||||
|
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
|
||||||
|
event = repository.get_events(member.member_id)[-1]
|
||||||
|
assert event.event_type == "welcome_email_sent"
|
||||||
|
assert event.data["recipient"] == "ada@example.org"
|
||||||
|
assert len(event.data["claim_ids"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_selected_claims_are_billed(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
admission = next(
|
||||||
|
claim for claim in open_claims(repository, member.member_id)
|
||||||
|
if claim["title"] == "Aufnahmegebühr"
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
claim_ids=[admission["claim_id"]],
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.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 "Aufnahmegebühr" in content
|
||||||
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
|
||||||
|
assert "Gesamtbetrag: 15.00 Euro" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
member.account_holder = "Ada Lovelace"
|
||||||
|
member.iban = "DE98670505050038907751"
|
||||||
|
member.mandate_reference = "MANDAT-42"
|
||||||
|
member.mandate_signed_at = "2026-08-20"
|
||||||
|
member.mandate_active = True
|
||||||
|
repository.save_member(member)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.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 "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
|
||||||
|
assert "Bitte überweise" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_edited_template_is_used_for_the_mail(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
repository.save_mail_template(
|
||||||
|
"welcome",
|
||||||
|
subject="Servus {{member.first_name}}",
|
||||||
|
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||||
|
assert message["Subject"] == "Servus Ada"
|
||||||
|
assert message.get_content().replace("\r\n", "\n").strip() == (
|
||||||
|
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.welcome_mail as welcome_mail_module
|
||||||
|
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
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(
|
||||||
|
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated.export_path is None
|
||||||
|
assert generated.archive_path.read_bytes() == sent[0]
|
||||||
|
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_without_email_is_refused(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
member.email = ""
|
||||||
|
repository.save_member(member)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
|
||||||
|
generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user