feat: add OpenDocument PDF templates

This commit is contained in:
Marcel Peterkau
2026-06-21 22:10:16 +02:00
parent b34135b34a
commit 0622a22794
14 changed files with 942 additions and 14 deletions
+19
View File
@@ -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:"):
+136
View File
@@ -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("<Escape>", 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("<<ComboboxSelected>>", 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)
+16
View File
@@ -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)])
+92 -14
View File
@@ -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("<Double-1>", lambda _event: self._open_selected_document())
self.documents.bind("<Return>", 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"