mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
feat: add OpenDocument PDF templates
This commit is contained in:
@@ -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:"):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user