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("<>", 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()