Files
CCMA/src/ccma/ui/welcome_mail_dialog.py
T
Marcel PeterkauandClaude Opus 5 aa0c16347b 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>
2026-08-28 20:49:08 +02:00

242 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()