from __future__ import annotations import tkinter as tk from collections.abc import Callable from email.parser import BytesParser from email.policy import default as default_policy from tkinter import filedialog, messagebox, ttk from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member from ccma.services.member_data_mail import ( data_review_mail_bytes, data_review_recipients, generate_data_review_mails, ) from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.email_delivery_dialog import resolve_delivery_mode class DataReviewMailDialog(tk.Toplevel): """Asks the members to check the data the club stores about them. Unlike the other mails this one goes out to everybody at once, so the recipients are picked from a list every member appears in -- with the active ones preselected.""" def __init__( self, master: tk.Misc, repository: MemberRepository, on_sent: Callable[[], None] | None = None, ): super().__init__(master) self.repository = repository self.on_sent = on_sent self.members: list[Member] = [] 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.warning_var = tk.StringVar() self.title("Datenüberprüfung anfragen") self.transient(master.winfo_toplevel()) self.geometry("940x620") self.minsize(760, 480) self._build_ui() self._refresh_members() self.after_idle(self.grab_set) def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(3, weight=1) ttk.Label( self, text=( "Bittet die ausgewählten Mitglieder, die über sie gespeicherten Daten zu " "prüfen. Jede Mail listet den Datensatz genau eines Mitglieds auf; die IBAN " "erscheint dabei nur mit den letzten vier Stellen. Betreff und Text stammen " "aus der Vorlage unter Optionen → E-Mail-Vorlagen." ), wraplength=880, ).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8)) ttk.Label( self, text=( "Vorausgewählt sind alle aktiven Mitgliedschaften mit E-Mail-Adresse – " "einzelne mit Strg+Klick ab- oder zusätzlich auswählen." ), style="Muted.TLabel", wraplength=880, ).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8)) ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid( row=2, column=0, sticky="w", padx=14, pady=(0, 6) ) table = ttk.Frame(self) table.grid(row=3, column=0, sticky="nsew", padx=14) table.columnconfigure(0, weight=1) table.rowconfigure(0, weight=1) self.table = ttk.Treeview( table, columns=("number", "name", "status", "email"), show="headings", selectmode="extended", ) for key, title, width, stretch in ( ("number", "Nummer", 90, False), ("name", "Name", 220, True), ("status", "Status", 200, False), ("email", "E-Mail", 260, True), ): self.table.heading(key, text=title) self.table.column(key, width=width, anchor="w", stretch=stretch) 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()) fields = ttk.Frame(self) fields.grid(row=4, column=0, sticky="ew", padx=14, pady=(10, 0)) 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.") footer = ttk.Frame(self, padding=14) footer.grid(row=5, column=0, sticky="ew") footer.columnconfigure(0, weight=1) ttk.Label(footer, textvariable=self.warning_var, style="Muted.TLabel", wraplength=520).grid( row=0, column=0, sticky="w" ) ttk.Button(footer, text="Abbrechen", command=self.destroy).grid(row=0, column=1, padx=(8, 0)) ttk.Button(footer, text="Vorschau", command=self._preview).grid(row=0, column=2, padx=(8, 0)) ttk.Button( footer, text="Mails erzeugen", style="Accent.TButton", command=self._generate ).grid(row=0, column=3, padx=(8, 0)) def _refresh_members(self) -> None: try: self.members = self.repository.list_members() preselected = { member.member_id for member in data_review_recipients(self.repository) } except RepositoryError as exc: messagebox.showerror("Mitglieder konnten nicht gelesen werden", str(exc), parent=self) return self.table.delete(*self.table.get_children()) for index, member in enumerate(self.members): self.table.insert( "", "end", iid=str(index), values=( member.member_number, member.display_name, MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), member.email or "—", ), ) self.table.selection_set( [ str(index) for index, member in enumerate(self.members) if member.member_id in preselected ] ) without_email = [member for member in self.members if not member.email.strip()] self.warning_var.set( f"{len(without_email)} Mitglied(er) ohne E-Mail-Adresse werden übersprungen." if without_email else "" ) self._update_summary() def _selected_members(self) -> list[Member]: return [self.members[int(iid)] for iid in sorted(self.table.selection(), key=int)] def _update_summary(self) -> None: selected = self._selected_members() reachable = [member for member in selected if member.email.strip()] self.summary_var.set( f"{len(selected)} von {len(self.members)} Mitgliedern ausgewählt – " f"{len(reachable)} Mail(s) werden erzeugt" ) def _preview(self) -> None: selected = self._selected_members() member = next((item for item in selected if item.email.strip()), None) if member is None: messagebox.showinfo( "Keine Vorschau möglich", "Bitte mindestens ein Mitglied mit E-Mail-Adresse auswählen.", parent=self, ) return try: content = data_review_mail_bytes( self.repository, member=member, 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"), ) except (OSError, RepositoryError) as exc: messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self) return message = BytesParser(policy=default_policy).parsebytes(content) window = tk.Toplevel(self) window.title(f"Vorschau für {member.display_name}") 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=30) 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: recipients = [member for member in self._selected_members() if member.email.strip()] if not recipients: messagebox.showinfo( "Keine Empfänger", "Bitte mindestens ein Mitglied mit E-Mail-Adresse auswählen.", parent=self, ) return delivery_mode = resolve_delivery_mode(self, self.repository) if delivery_mode is None: return output_directory = None if delivery_mode == "local": selected = filedialog.askdirectory( parent=self, title="Ausgabeordner für die Thunderbird-Entwürfe auswählen", mustexist=True, ) if not selected: return output_directory = selected # A mailing to the whole club cannot be taken back, so the count is confirmed # once more -- especially when "Senden" would put it on the wire right away. action = { "local": "als Datei abgelegt", "send": "sofort versandt", "drafts": "als Entwurf im Postfach abgelegt", }[delivery_mode] if not messagebox.askyesno( "Datenüberprüfung verschicken", f"An {len(recipients)} Mitglied(er) wird je eine Mail mit deren gespeicherten " f"Daten {action}.\n\nJetzt erzeugen?", parent=self, ): return self.configure(cursor="watch") self.update_idletasks() try: generated, warnings = generate_data_review_mails( self.repository, [member.member_id for member in recipients], delivery_mode=delivery_mode, output_directory=output_directory, 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("Mails konnten nicht erzeugt werden", str(exc), parent=self) return finally: self.configure(cursor="") location = { "local": f"in\n{output_directory}\nerzeugt", "send": "versandt", "drafts": "als Entwurf im Postfach abgelegt", }[delivery_mode] detail = ( f"{len(generated)} Mail(s) wurden {location} und in den Mitgliederakten archiviert." ) if warnings: detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12]) if len(warnings) > 12: detail += f"\n… und {len(warnings) - 12} weitere." messagebox.showinfo("Datenüberprüfung verschickt", detail, parent=self) self.destroy() if self.on_sent is not None: self.on_sent()