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
@@ -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()
|
||||
Reference in New Issue
Block a user