diff --git a/README.md b/README.md index 312377a..f44edea 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ directory containing `member.json`, `contributions.json`, an append-only ## Development Requires Python 3.11+ with Tk support. +PDF generation from document templates additionally requires LibreOffice or +OpenOffice with a `soffice` command available on the system. ```bash python -m venv .venv @@ -46,6 +48,10 @@ member-store/ ├── repository.json ├── housekeeper.json ├── rules/ +├── templates/ +│ ├── Forderung.fodt +│ ├── Mahnung.fodt +│ └── Mitglied.fodt └── members/ └── / ├── member.json @@ -54,6 +60,33 @@ member-store/ └── files/ ``` +## Document templates + +CCMA reads OpenDocument templates (`.fodt`, `.odt`, or `.ott`) from the +store's `templates/` directory. The three initial `.fodt` files are editable +with LibreOffice/OpenOffice and are copied only when their filename does not +already exist. PDF files are generated locally and stored below the member's +`files/documents/` directory. No office document content is sent to an +external service. + +Placeholders use `{{group.field}}`. Available values include: + +- Member: `member.number`, `member.first_name`, `member.last_name`, + `member.full_name`, `member.email`, `member.birth_date`, `member.status`, + `member.accepted_at`, `member.started_at` +- Claim: `claim.id`, `claim.title`, `claim.due_date`, `claim.total`, + `claim.paid`, `claim.balance`, `claim.status`, `claim.items` +- Reminder: `reminder.id`, `reminder.level`, `reminder.name`, + `reminder.status`, `reminder.created_at`, `reminder.sent_at`, + `reminder.payment_deadline`, `reminder.payment_deadline_days`, + `reminder.fee`, `reminder.detail`, `reminder.channel` +- Document: `document.date`, `document.datetime` + +Claim placeholders are available from a claim tab. Reminder placeholders are +available when a reminder row is selected before opening the document dialog. +Unknown or unavailable placeholders stop generation with a clear error rather +than producing an incomplete letter. + ## Housekeeper rules The housekeeper runs every rule for every member. Built-in Python rules live in diff --git a/pyproject.toml b/pyproject.toml index c28125d..5dcea7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ ccma = [ "VERSION", "assets/CHANGELOG.json", "assets/splash.png", + "assets/templates/*", "assets/themes/forest/**/*", "assets/themes/forest/*", ] diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 93e6a37..f7985be 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -25,6 +25,7 @@ "Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.", "Forderungen besitzen eigene Arbeitsansichten mit Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.", "Positionen, Zahlungen und Mahnungen einer Forderung werden gemeinsam in einer farblich gruppierten Übersicht dargestellt.", + "OpenDocument-Templates mit Platzhaltern, lokaler PDF-Erzeugung, Audit-Verknüpfung und Dokumentöffnung aus der Mitgliederakte ergänzt.", "Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.", "Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.", "Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.", diff --git a/src/ccma/assets/templates/Forderung.fodt b/src/ccma/assets/templates/Forderung.fodt new file mode 100644 index 0000000..995004b --- /dev/null +++ b/src/ccma/assets/templates/Forderung.fodt @@ -0,0 +1,23 @@ + + + + + + + + + Chaos Computer Club Mannheim e.V. + Erstellt am {{document.date}} + Forderung: {{claim.title}} + Mitglied: {{member.full_name}} ({{member.number}}) + E-Mail: {{member.email}} + Fällig am: {{claim.due_date}} + Positionen + {{claim.items}} + Gesamtbetrag: {{claim.total}} + Bereits bezahlt: {{claim.paid}} + Offener Betrag: {{claim.balance}} + Forderungs-ID: {{claim.id}} + + + diff --git a/src/ccma/assets/templates/Mahnung.fodt b/src/ccma/assets/templates/Mahnung.fodt new file mode 100644 index 0000000..fe34e4d --- /dev/null +++ b/src/ccma/assets/templates/Mahnung.fodt @@ -0,0 +1,21 @@ + + + + + + + + + Chaos Computer Club Mannheim e.V. + {{document.date}} + An {{member.full_name}} ({{member.number}}) + {{reminder.name}} + Zu unserer Forderung „{{claim.title}}“ ist weiterhin ein Betrag von {{claim.balance}} offen. + Bitte begleiche den offenen Betrag innerhalb von {{reminder.payment_deadline_days}} Tagen. + Mahngebühr: {{reminder.fee}} + {{reminder.detail}} + Forderungs-ID: {{claim.id}} + Mahnstufe: {{reminder.level}} + + + diff --git a/src/ccma/assets/templates/Mitglied.fodt b/src/ccma/assets/templates/Mitglied.fodt new file mode 100644 index 0000000..8ad25d4 --- /dev/null +++ b/src/ccma/assets/templates/Mitglied.fodt @@ -0,0 +1,20 @@ + + + + + + + + + Chaos Computer Club Mannheim e.V. + Erstellt am {{document.date}} + Mitgliedsdaten + Name: {{member.full_name}} + Mitgliedsnummer: {{member.number}} + E-Mail: {{member.email}} + Geburtsdatum: {{member.birth_date}} + Mitglied seit: {{member.started_at}} + Status: {{member.status}} + + + diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py new file mode 100644 index 0000000..6c8c0f3 --- /dev/null +++ b/src/ccma/services/documents.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import hashlib +import os +import re +import shutil +import subprocess +import tempfile +import zipfile +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path +from xml.etree import ElementTree + +from ccma.domain.contributions import ( + CLAIM_STATUS_LABELS, + allocated_total, + claim_balance, + claim_items, + claim_status, + claim_total, + money_text, +) +from ccma.domain.dates import format_date_for_display +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member +from ccma.storage.repository import MemberRepository + +SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"} +PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE) + + +class DocumentError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class DocumentTemplate: + name: str + path: Path + relative_path: str + + +@dataclass(frozen=True, slots=True) +class GeneratedDocument: + path: Path + relative_path: str + sha256: str + + +class DocumentService: + def __init__(self, repository: MemberRepository): + self.repository = repository + self.templates_root = repository.root / "templates" + + def list_templates(self) -> list[DocumentTemplate]: + templates: list[DocumentTemplate] = [] + if not self.templates_root.is_dir(): + return templates + for path in sorted(self.templates_root.rglob("*")): + if path.is_file() and path.suffix.casefold() in SUPPORTED_TEMPLATE_SUFFIXES: + relative = path.relative_to(self.templates_root).as_posix() + templates.append(DocumentTemplate(path.stem.replace("_", " "), path, relative)) + return templates + + def compatible_templates( + self, + *, + has_claim: bool, + has_reminder: bool, + ) -> list[DocumentTemplate]: + compatible: list[DocumentTemplate] = [] + for template in self.list_templates(): + fields = _template_fields(template.path) + if not has_claim and any(field.startswith("claim.") for field in fields): + continue + if not has_reminder and any(field.startswith("reminder.") for field in fields): + continue + compatible.append(template) + return compatible + + def generate( + self, + template: DocumentTemplate, + member_id: str, + *, + output_name: str, + claim_id: str | None = None, + reminder_id: str | None = None, + ) -> GeneratedDocument: + template_path = template.path.resolve() + try: + template_path.relative_to(self.templates_root.resolve()) + except ValueError as exc: + raise DocumentError("Das Template liegt nicht im Template-Verzeichnis.") from exc + if not template_path.is_file() or template_path.suffix.casefold() not in SUPPORTED_TEMPLATE_SUFFIXES: + raise DocumentError("Das ausgewählte OpenDocument-Template ist nicht verfügbar.") + + member = self.repository.get_member(member_id) + data = None + claim = None + reminder = None + if claim_id: + data, claim = self.repository.get_claim(member_id, claim_id) + if reminder_id: + if not claim_id or data is None: + raise DocumentError("Eine Mahnung benötigt den Kontext ihrer Forderung.") + reminder = next( + ( + item + for item in data.reminders + if str(item.get("claim_id", "")) == claim_id + and str(item.get("reminder_id", "")) == reminder_id + ), + None, + ) + if reminder is None: + raise DocumentError("Die ausgewählte Mahnung wurde nicht gefunden.") + + values = _template_values(member, data=data, claim=claim, reminder=reminder) + destination_dir = self.repository.members_root / member_id / "files" / "documents" + destination_dir.mkdir(parents=True, exist_ok=True) + destination = _available_path(destination_dir, _safe_pdf_name(output_name)) + + with tempfile.TemporaryDirectory(prefix="ccma-document-") as temporary_name: + temporary = Path(temporary_name) + rendered = temporary / f"rendered{template_path.suffix.casefold()}" + _render_template(template_path, rendered, values) + converted = _convert_to_pdf(rendered, temporary) + temporary_destination = destination.with_name(f".{destination.name}.tmp") + try: + shutil.copyfile(converted, temporary_destination) + os.replace(temporary_destination, destination) + finally: + temporary_destination.unlink(missing_ok=True) + + relative_path = destination.relative_to( + self.repository.members_root / member_id / "files" + ).as_posix() + digest = hashlib.sha256(destination.read_bytes()).hexdigest() + if reminder_id and claim_id: + self.repository.register_reminder_document( + member_id, + claim_id, + reminder_id, + relative_path=relative_path, + sha256=digest, + template=template.relative_path, + ) + else: + references = {"document": relative_path} + if claim_id: + references["claim_id"] = claim_id + self.repository.append_event( + member_id, + event_type="document_generated", + summary=f"Dokument erzeugt: {destination.name}", + actor_type="user", + actor_name="Vorstand", + references=references, + data={"template": template.relative_path, "sha256": digest}, + ) + return GeneratedDocument(destination, relative_path, digest) + + +def _template_values( + member: Member, + *, + data=None, + claim: dict | None = None, + reminder: dict | None = None, +) -> dict[str, str]: + values = { + "document.date": format_date_for_display(date.today().isoformat()), + "document.datetime": datetime.now().astimezone().strftime("%d.%m.%Y %H:%M"), + "member.id": member.member_id, + "member.number": member.member_number, + "member.first_name": member.first_name, + "member.last_name": member.last_name, + "member.full_name": member.display_name, + "member.email": member.email, + "member.birth_date": format_date_for_display(member.birth_date), + "member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), + "member.accepted_at": format_date_for_display(member.accepted_at), + "member.started_at": format_date_for_display(member.membership_started_at), + } + if claim is not None and data is not None: + claim_id = str(claim.get("claim_id", "")) + item_lines = [ + f"{item.get('description', '')}: {item.get('amount', '0.00')} EUR" + for item in claim_items(claim) + ] + status = claim_status(data, claim) + values.update( + { + "claim.id": claim_id, + "claim.title": str(claim.get("title", "")), + "claim.due_date": format_date_for_display(str(claim.get("due_date", ""))), + "claim.total": f"{money_text(claim_total(claim))} EUR", + "claim.paid": f"{money_text(allocated_total(data, claim_id))} EUR", + "claim.balance": f"{money_text(claim_balance(data, claim))} EUR", + "claim.status": CLAIM_STATUS_LABELS.get(status, status), + "claim.items": "; ".join(item_lines), + } + ) + if reminder is not None: + reminder_status_labels = { + "draft": "Entwurf", + "generated": "Dokument erzeugt", + "sent": "Versandt", + "cancelled": "Verworfen", + } + channel_labels = {"email": "E-Mail", "letter": "Brief", "personal": "Persönlich"} + status = str(reminder.get("status", "draft")) + channel = str(reminder.get("channel", "")) + values.update( + { + "reminder.id": str(reminder.get("reminder_id", "")), + "reminder.level": str(reminder.get("level", "")), + "reminder.name": str(reminder.get("name", "")), + "reminder.status": reminder_status_labels.get(status, status), + "reminder.created_at": _display_timestamp(str(reminder.get("created_at", ""))), + "reminder.sent_at": _display_timestamp(str(reminder.get("sent_at") or "")), + "reminder.payment_deadline": format_date_for_display( + str(reminder.get("payment_deadline") or "") + ), + "reminder.payment_deadline_days": str(reminder.get("payment_deadline_days", "")), + "reminder.fee": f"{reminder.get('fee', '0.00')} EUR", + "reminder.detail": str(reminder.get("detail", "")), + "reminder.channel": channel_labels.get(channel, channel), + } + ) + return values + + +def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None: + if source.suffix.casefold() == ".fodt": + try: + content = source.read_bytes() + except OSError as exc: + raise DocumentError(f"Template konnte nicht gelesen werden: {exc}") from exc + destination.write_bytes(_replace_xml_placeholders(content, values)) + return + try: + with zipfile.ZipFile(source, "r") as archive, zipfile.ZipFile(destination, "w") as output: + for info in archive.infolist(): + content = archive.read(info.filename) + if info.filename in {"content.xml", "styles.xml"}: + content = _replace_xml_placeholders(content, values) + output.writestr(info, content) + except (OSError, zipfile.BadZipFile) as exc: + raise DocumentError(f"OpenDocument-Template ist beschädigt: {exc}") from exc + + +def _template_fields(source: Path) -> set[str]: + try: + if source.suffix.casefold() == ".fodt": + contents = [source.read_bytes()] + else: + with zipfile.ZipFile(source, "r") as archive: + contents = [ + archive.read(name) + for name in ("content.xml", "styles.xml") + if name in archive.namelist() + ] + except (OSError, zipfile.BadZipFile): + return set() + fields: set[str] = set() + for content in contents: + try: + root = ElementTree.fromstring(content) + except ElementTree.ParseError: + continue + for paragraph in root.iter(): + if _local_name(paragraph.tag) in {"p", "h"}: + combined = "".join(value for _node, _attribute, value in _text_slots(paragraph)) + fields.update(match.group(1) for match in PLACEHOLDER_PATTERN.finditer(combined)) + return fields + + +def _replace_xml_placeholders(content: bytes, values: dict[str, str]) -> bytes: + try: + root = ElementTree.fromstring(content) + except ElementTree.ParseError as exc: + raise DocumentError(f"Template-XML ist beschädigt: {exc}") from exc + unknown: set[str] = set() + for paragraph in root.iter(): + if _local_name(paragraph.tag) not in {"p", "h"}: + continue + slots = _text_slots(paragraph) + combined = "".join(value for _element, _attribute, value in slots) + matches = list(PLACEHOLDER_PATTERN.finditer(combined)) + unknown.update(match.group(1) for match in matches if match.group(1) not in values) + _replace_matches(slots, matches, values) + if unknown: + names = ", ".join(sorted(unknown)) + raise DocumentError(f"Unbekannte oder im Kontext nicht verfügbare Platzhalter: {names}") + return ElementTree.tostring(root, encoding="utf-8", xml_declaration=True) + + +def _text_slots(element) -> list[tuple[object, str, str]]: + slots: list[tuple[object, str, str]] = [] + + def collect(node) -> None: + if node.text: + slots.append((node, "text", node.text)) + for child in node: + collect(child) + if child.tail: + slots.append((child, "tail", child.tail)) + + collect(element) + return slots + + +def _replace_matches(slots, matches, values: dict[str, str]) -> None: + boundaries: list[tuple[int, int]] = [] + offset = 0 + for _element, _attribute, text in slots: + boundaries.append((offset, offset + len(text))) + offset += len(text) + for match in reversed(matches): + key = match.group(1) + if key not in values: + continue + start_index, start_offset = _slot_at(boundaries, match.start()) + end_index, end_offset = _slot_at(boundaries, match.end() - 1) + start_element, start_attribute, start_text = slots[start_index] + end_element, end_attribute, end_text = slots[end_index] + end_offset += 1 + if start_index == end_index: + replacement = start_text[:start_offset] + values[key] + start_text[end_offset:] + setattr(start_element, start_attribute, replacement) + slots[start_index] = (start_element, start_attribute, replacement) + continue + replacement = start_text[:start_offset] + values[key] + setattr(start_element, start_attribute, replacement) + slots[start_index] = (start_element, start_attribute, replacement) + for index in range(start_index + 1, end_index): + current_element, current_attribute, _current_text = slots[index] + setattr(current_element, current_attribute, "") + slots[index] = (current_element, current_attribute, "") + suffix = end_text[end_offset:] + setattr(end_element, end_attribute, suffix) + slots[end_index] = (end_element, end_attribute, suffix) + + +def _slot_at(boundaries: list[tuple[int, int]], position: int) -> tuple[int, int]: + for index, (start, end) in enumerate(boundaries): + if start <= position < end: + return index, position - start + raise DocumentError("Platzhalter konnte im Template nicht zugeordnet werden.") + + +def _convert_to_pdf(source: Path, output_directory: Path) -> Path: + executable = shutil.which("soffice") or shutil.which("libreoffice") + if not executable: + raise DocumentError("LibreOffice/OpenOffice wurde nicht gefunden; PDF-Erzeugung ist nicht möglich.") + profile = output_directory / "libreoffice-profile" + command = [ + executable, + f"-env:UserInstallation={profile.resolve().as_uri()}", + "--headless", + "--convert-to", + "pdf", + "--outdir", + str(output_directory), + str(source), + ] + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise DocumentError(f"PDF-Konvertierung konnte nicht ausgeführt werden: {exc}") from exc + converted = output_directory / f"{source.stem}.pdf" + if result.returncode != 0 or not converted.is_file(): + detail = (result.stderr or result.stdout).strip() or "keine PDF-Datei erzeugt" + raise DocumentError(f"LibreOffice konnte das Dokument nicht konvertieren: {detail}") + return converted + + +def _safe_pdf_name(value: str) -> str: + stem = Path(value.strip()).stem + stem = re.sub(r"[^A-Za-z0-9ÄÖÜäöüß._ -]+", "-", stem).strip(" .-") + if not stem: + stem = f"Dokument-{date.today().isoformat()}" + return f"{stem}.pdf" + + +def _available_path(directory: Path, filename: str) -> Path: + candidate = directory / filename + counter = 2 + while candidate.exists(): + candidate = directory / f"{Path(filename).stem}-{counter}.pdf" + counter += 1 + return candidate + + +def _display_timestamp(value: str) -> str: + if not value: + return "" + try: + return datetime.fromisoformat(value).strftime("%d.%m.%Y %H:%M") + except ValueError: + return value[:16] + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 226512c..b5b7f50 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import os +import shutil import unicodedata from collections.abc import Iterable from datetime import date, datetime, timedelta @@ -88,6 +89,14 @@ class MemberRepository: def initialize(self) -> None: self.members_root.mkdir(parents=True, exist_ok=True) (self.root / "rules").mkdir(parents=True, exist_ok=True) + templates_root = self.root / "templates" + templates_root.mkdir(parents=True, exist_ok=True) + builtin_templates = Path(__file__).resolve().parent.parent / "assets" / "templates" + if builtin_templates.is_dir(): + for source in builtin_templates.iterdir(): + destination = templates_root / source.name + if source.is_file() and not destination.exists(): + shutil.copyfile(source, destination) config_path = self.root / "repository.json" if not config_path.exists(): write_json_atomic(config_path, DEFAULT_CONFIGURATION) @@ -541,6 +550,45 @@ class MemberRepository: references={"claim_id": claim_id, "reminder_id": reminder_id}, ) + def register_reminder_document( + self, + member_id: str, + claim_id: str, + reminder_id: str, + *, + relative_path: str, + sha256: str, + template: str, + ) -> None: + document_path = Path(relative_path) + if document_path.is_absolute() or ".." in document_path.parts: + raise RepositoryError("Der Dokumentpfad muss innerhalb des Mitglieder-Dateiordners liegen.") + data, _claim = self.get_claim(member_id, claim_id) + reminder = self._find_reminder(data, claim_id, reminder_id) + reminder["document"] = { + "path": relative_path, + "sha256": sha256, + "template": template, + "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + if str(reminder.get("status", "draft")) == "draft": + reminder["status"] = "generated" + reminder["generated_at"] = reminder["document"]["generated_at"] + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="reminder_document_generated", + summary=f"Mahndokument erzeugt: {Path(relative_path).name}", + actor_type="user", + actor_name="Vorstand", + references={ + "claim_id": claim_id, + "reminder_id": reminder_id, + "document": relative_path, + }, + data={"template": template, "sha256": sha256}, + ) + def set_dunning_hold( self, member_id: str, diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 92b7b97..203daf9 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -4,6 +4,7 @@ import tkinter as tk from collections.abc import Callable from datetime import date from decimal import Decimal +from pathlib import Path from tkinter import messagebox, ttk from ccma.domain.contributions import ( @@ -19,6 +20,7 @@ from ccma.domain.contributions import ( ) from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.labels import ( CLAIM_ITEM_TYPE_LABELS, REMINDER_CHANNEL_LABELS, @@ -145,6 +147,9 @@ class ClaimTab(ttk.Frame): ) self.send_reminder_button.pack(side="right", padx=(8, 0)) ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") + ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( + side="right", padx=(0, 8) + ) def refresh(self) -> None: try: @@ -307,6 +312,20 @@ class ClaimTab(ttk.Frame): def _add_reminder(self) -> None: ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) + def _create_document(self) -> None: + reminder = self._selected_reminder() + DocumentTemplateDialog( + self, + self.repository, + self.member_id, + self._document_generated, + claim_id=self.claim_id, + reminder_id=str(reminder["reminder_id"]) if reminder else None, + ) + + def _document_generated(self, _path: Path) -> None: + self._changed() + def _selected_reminder(self) -> dict | None: selected = self.ledger.selection() if not selected or not selected[0].startswith("reminder:"): diff --git a/src/ccma/ui/document_dialog.py b/src/ccma/ui/document_dialog.py new file mode 100644 index 0000000..4bdc6ed --- /dev/null +++ b/src/ccma/ui/document_dialog.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from datetime import date +from pathlib import Path +from tkinter import messagebox, ttk + +from ccma.services.documents import DocumentError, DocumentService, GeneratedDocument +from ccma.storage.repository import MemberRepository, RepositoryError + + +class DocumentTemplateDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + member_id: str, + on_generated: Callable[[Path], None], + *, + claim_id: str | None = None, + reminder_id: str | None = None, + ): + super().__init__(master) + self.service = DocumentService(repository) + self.member_id = member_id + self.claim_id = claim_id + self.reminder_id = reminder_id + self.on_generated = on_generated + self.templates = self.service.compatible_templates( + has_claim=claim_id is not None, + has_reminder=reminder_id is not None, + ) + self.template_by_label = { + f"{template.name} ({template.relative_path})": template for template in self.templates + } + self.title("Dokument aus Template erzeugen") + self.transient(master.winfo_toplevel()) + self.resizable(False, False) + self.template_var = tk.StringVar() + self.filename_var = tk.StringVar() + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.after_idle(self._grab_if_visible) + + def _grab_if_visible(self) -> None: + try: + if self.winfo_exists() and self.winfo_viewable(): + self.grab_set() + except tk.TclError: + pass + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + ttk.Label(frame, text="OpenDocument-Template").grid( + row=0, column=0, sticky="w", pady=5, padx=(0, 12) + ) + template_box = ttk.Combobox( + frame, + textvariable=self.template_var, + values=list(self.template_by_label), + state="readonly", + width=56, + ) + template_box.grid(row=0, column=1, sticky="ew", pady=5) + template_box.bind("<>", lambda _event: self._template_selected()) + ttk.Label(frame, text="PDF-Dateiname").grid( + row=1, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(frame, textvariable=self.filename_var, width=58).grid( + row=1, column=1, sticky="ew", pady=5 + ) + context = "Mitglied" + if self.claim_id: + context += " · Forderung" + if self.reminder_id: + context += " · ausgewählte Mahnung" + ttk.Label(frame, text=f"Kontext: {context}", style="Mono.TLabel").grid( + row=2, column=0, columnspan=2, sticky="w", pady=(8, 0) + ) + ttk.Label( + frame, + text="Das PDF wird unter files/documents in der Mitgliederakte abgelegt.", + style="Muted.TLabel", + ).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0)) + buttons = ttk.Frame(frame) + buttons.grid(row=4, column=0, columnspan=2, sticky="e", pady=(18, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + self.generate_button = ttk.Button( + buttons, + text="PDF erzeugen", + style="Accent.TButton", + command=self._generate, + ) + self.generate_button.pack(side="left") + if self.template_by_label: + self.template_var.set(next(iter(self.template_by_label))) + self._template_selected() + else: + self.generate_button.configure(state="disabled") + ttk.Label( + frame, + text="Für diesen Kontext ist kein passendes Template vorhanden.", + style="Warning.TLabel", + ).grid(row=5, column=0, columnspan=2, sticky="w", pady=(12, 0)) + + def _template_selected(self) -> None: + template = self.template_by_label.get(self.template_var.get()) + if template: + self.filename_var.set(f"{date.today().isoformat()} - {template.name}.pdf") + + def _generate(self) -> None: + template = self.template_by_label.get(self.template_var.get()) + if template is None: + messagebox.showerror("Template auswählen", "Bitte ein Template auswählen.", parent=self) + return + self.generate_button.configure(state="disabled") + self.configure(cursor="watch") + self.update_idletasks() + try: + generated: GeneratedDocument = self.service.generate( + template, + self.member_id, + output_name=self.filename_var.get(), + claim_id=self.claim_id, + reminder_id=self.reminder_id, + ) + except (DocumentError, RepositoryError, OSError) as exc: + messagebox.showerror("Dokument konnte nicht erzeugt werden", str(exc), parent=self) + self.generate_button.configure(state="normal") + self.configure(cursor="") + return + self.destroy() + self.on_generated(generated.path) + messagebox.showinfo("Dokument erzeugt", f"PDF gespeichert:\n{generated.path}", parent=self.master) diff --git a/src/ccma/ui/file_open.py b/src/ccma/ui/file_open.py new file mode 100644 index 0000000..d5afa07 --- /dev/null +++ b/src/ccma/ui/file_open.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def open_path(path: Path) -> None: + selected = path.resolve() + if sys.platform == "win32": + os.startfile(selected) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", str(selected)]) + else: + subprocess.Popen(["xdg-open", str(selected)]) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index e96655d..b0b5ed3 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -1,10 +1,9 @@ from __future__ import annotations -import subprocess -import sys import tkinter as tk from collections.abc import Callable from datetime import datetime +from pathlib import Path from tkinter import messagebox, ttk from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text @@ -12,6 +11,8 @@ from ccma.domain.dates import age_label, date_input_hint, format_date_for_displa from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import Event from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.document_dialog import DocumentTemplateDialog +from ccma.ui.file_open import open_path from ccma.ui.labels import display_label, storage_key @@ -161,11 +162,41 @@ class MemberTab(ttk.Frame): documents_tab.columnconfigure(0, weight=1) documents_tab.rowconfigure(1, weight=1) - ttk.Button(documents_tab, text="Dateiordner öffnen", command=self._open_files).grid( - row=0, column=0, sticky="w", pady=(0, 10) + document_buttons = ttk.Frame(documents_tab) + document_buttons.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + ttk.Button( + document_buttons, + text="Dokument aus Template", + style="Accent.TButton", + command=self._create_document, + ).pack(side="left", padx=(0, 8)) + ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack( + side="left" ) - self.documents = tk.Listbox(documents_tab, borderwidth=0, highlightthickness=0) + self.documents = ttk.Treeview( + documents_tab, + columns=("name", "type", "modified", "size"), + show="headings", + ) + for key, title, width in ( + ("name", "Dokument", 280), + ("type", "Typ", 65), + ("modified", "Geändert", 135), + ("size", "Größe", 80), + ): + self.documents.heading(key, text=title) + self.documents.column(key, width=width, anchor="w") self.documents.grid(row=1, column=0, sticky="nsew") + document_scroll = ttk.Scrollbar( + documents_tab, + orient="vertical", + command=self.documents.yview, + ) + document_scroll.grid(row=1, column=1, sticky="ns") + self.documents.configure(yscrollcommand=document_scroll.set) + self.documents.bind("", lambda _event: self._open_selected_document()) + self.documents.bind("", lambda _event: self._open_selected_document()) + self.document_paths: dict[str, Path] = {} def _build_timeline(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) @@ -244,11 +275,31 @@ class MemberTab(ttk.Frame): self.on_open_claim(self.member_id, selected[0]) def _refresh_documents(self) -> None: - self.documents.delete(0, "end") + self.documents.delete(*self.documents.get_children()) + self.document_paths.clear() root = self.repository.members_root / self.member_id / "files" - for path in sorted(root.rglob("*")): + for index, path in enumerate(sorted(root.rglob("*"))): if path.is_file(): - self.documents.insert("end", str(path.relative_to(root))) + item_id = f"document:{index}" + self.document_paths[item_id] = path + try: + stat = path.stat() + modified = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M") + size = _file_size(stat.st_size) + except OSError: + modified = "—" + size = "—" + self.documents.insert( + "", + "end", + iid=item_id, + values=( + path.relative_to(root), + path.suffix.removeprefix(".").upper() or "DATEI", + modified, + size, + ), + ) def _save(self) -> None: for key, variable in self.variables.items(): @@ -280,12 +331,31 @@ class MemberTab(ttk.Frame): def _open_files(self) -> None: path = self.repository.members_root / self.member_id / "files" - if sys.platform == "win32": - subprocess.Popen(["explorer", str(path)]) - elif sys.platform == "darwin": - subprocess.Popen(["open", str(path)]) - else: - subprocess.Popen(["xdg-open", str(path)]) + self._open_path(path) + + def _open_selected_document(self) -> None: + selected = self.documents.selection() + if selected and selected[0] in self.document_paths: + self._open_path(self.document_paths[selected[0]]) + + def _open_path(self, path: Path) -> None: + try: + open_path(path) + except OSError as exc: + messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self) + + def _create_document(self) -> None: + DocumentTemplateDialog( + self, + self.repository, + self.member_id, + self._document_generated, + ) + + def _document_generated(self, _path: Path) -> None: + self._refresh_documents() + self._refresh_events() + self.on_changed() def _format_timestamp(event: Event) -> str: @@ -299,3 +369,11 @@ def _event_label(event: Event) -> str: if event.actor_type == "system": return f"[AUTO] {event.summary}" return event.summary + + +def _file_size(size: int) -> str: + if size < 1024: + return f"{size} B" + if size < 1024 * 1024: + return f"{size / 1024:.1f} KiB" + return f"{size / (1024 * 1024):.1f} MiB" diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..5e41046 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import date +from xml.etree import ElementTree + +import pytest + +import ccma.services.documents as document_module +from ccma.services.documents import DocumentError, DocumentService, _replace_xml_placeholders +from ccma.services.housekeeper import Housekeeper +from ccma.storage.repository import MemberRepository, RepositoryError + + +def test_placeholder_replacement_crosses_formatted_odf_spans() -> None: + source = b""" + + Hello {{member.full_name}}! + """ + + rendered = _replace_xml_placeholders(source, {"member.full_name": "Ada Lovelace"}) + text = "".join(ElementTree.fromstring(rendered).itertext()) + + assert "Hello Ada Lovelace!" in text + assert "{{" not in text + + +def test_unknown_placeholder_is_reported() -> None: + source = b"""

{{member.unknown}}

""" + + with pytest.raises(DocumentError, match="member.unknown"): + _replace_xml_placeholders(source, {}) + + +def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + template = repository.root / "templates" / "Mitglied.fodt" + template.write_text("custom", encoding="utf-8") + + repository.initialize() + + assert template.read_text(encoding="utf-8") == "custom" + assert {path.name for path in (repository.root / "templates").iterdir()} == { + "Forderung.fodt", + "Mahnung.fodt", + "Mitglied.fodt", + } + + +def test_templates_are_filtered_by_available_context(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + service = DocumentService(repository) + + member_templates = { + item.path.name for item in service.compatible_templates(has_claim=False, has_reminder=False) + } + claim_templates = { + item.path.name for item in service.compatible_templates(has_claim=True, has_reminder=False) + } + reminder_templates = { + item.path.name for item in service.compatible_templates(has_claim=True, has_reminder=True) + } + + assert member_templates == {"Mitglied.fodt"} + assert claim_templates == {"Forderung.fodt", "Mitglied.fodt"} + assert reminder_templates == {"Forderung.fodt", "Mahnung.fodt", "Mitglied.fodt"} + + +def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypatch) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + member.status = "active" + member.accepted_at = "2026-01-01" + member.membership_started_at = "2026-01-01" + repository.save_member(member) + Housekeeper(repository).run(today=date(2026, 1, 1)) + claim = repository.get_contributions(member.member_id).claims[0] + claim_id = str(claim["claim_id"]) + reminder = repository.create_reminder_draft( + member.member_id, + claim_id, + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + ) + + def fake_convert(source, output_directory): + destination = output_directory / f"{source.stem}.pdf" + destination.write_bytes(b"%PDF-1.7\ntest") + return destination + + monkeypatch.setattr(document_module, "_convert_to_pdf", fake_convert) + service = DocumentService(repository) + template = next(item for item in service.list_templates() if item.path.name == "Mahnung.fodt") + + generated = service.generate( + template, + member.member_id, + output_name="Mahnung.pdf", + claim_id=claim_id, + reminder_id=str(reminder["reminder_id"]), + ) + + assert generated.path.read_bytes().startswith(b"%PDF-") + assert generated.relative_path == "documents/Mahnung.pdf" + stored = repository.get_contributions(member.member_id) + updated = next(item for item in stored.reminders if item["reminder_id"] == reminder["reminder_id"]) + assert updated["status"] == "generated" + assert updated["document"]["path"] == generated.relative_path + assert updated["document"]["sha256"] == generated.sha256 + assert repository.get_events(member.member_id)[-1].event_type == "reminder_document_generated" + + with pytest.raises(RepositoryError, match="innerhalb"): + repository.register_reminder_document( + member.member_id, + claim_id, + str(reminder["reminder_id"]), + relative_path="../outside.pdf", + sha256="invalid", + template="Mahnung.fodt", + ) diff --git a/tests/test_repository.py b/tests/test_repository.py index 3e15236..28afb8d 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -13,6 +13,7 @@ from ccma.storage.repository import ( def test_repository_creates_transparent_member_record(tmp_path) -> None: repository = MemberRepository(tmp_path / "store") repository.initialize() + assert (repository.root / "templates").is_dir() member = repository.create_member( first_name="Ada",