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
+1
View File
@@ -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.",
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:mimetype="application/vnd.oasis.opendocument.text" office:version="1.3">
<office:styles>
<style:style style:name="Title" style:family="paragraph"><style:paragraph-properties fo:margin-bottom="0.4cm"/><style:text-properties fo:font-size="18pt" fo:font-weight="bold"/></style:style>
<style:style style:name="Heading" style:family="paragraph"><style:paragraph-properties fo:margin-top="0.35cm" fo:margin-bottom="0.15cm"/><style:text-properties fo:font-size="12pt" fo:font-weight="bold"/></style:style>
</office:styles>
<office:body>
<office:text>
<text:p text:style-name="Title">Chaos Computer Club Mannheim e.V.</text:p>
<text:p>Erstellt am {{document.date}}</text:p>
<text:p text:style-name="Heading">Forderung: {{claim.title}}</text:p>
<text:p>Mitglied: {{member.full_name}} ({{member.number}})</text:p>
<text:p>E-Mail: {{member.email}}</text:p>
<text:p>Fällig am: {{claim.due_date}}</text:p>
<text:p text:style-name="Heading">Positionen</text:p>
<text:p>{{claim.items}}</text:p>
<text:p>Gesamtbetrag: {{claim.total}}</text:p>
<text:p>Bereits bezahlt: {{claim.paid}}</text:p>
<text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</text:p>
<text:p>Forderungs-ID: {{claim.id}}</text:p>
</office:text>
</office:body>
</office:document>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:mimetype="application/vnd.oasis.opendocument.text" office:version="1.3">
<office:styles>
<style:style style:name="Title" style:family="paragraph"><style:paragraph-properties fo:margin-bottom="0.4cm"/><style:text-properties fo:font-size="18pt" fo:font-weight="bold"/></style:style>
<style:style style:name="Heading" style:family="paragraph"><style:paragraph-properties fo:margin-top="0.35cm" fo:margin-bottom="0.15cm"/><style:text-properties fo:font-size="12pt" fo:font-weight="bold"/></style:style>
</office:styles>
<office:body>
<office:text>
<text:p text:style-name="Title">Chaos Computer Club Mannheim e.V.</text:p>
<text:p>{{document.date}}</text:p>
<text:p>An {{member.full_name}} ({{member.number}})</text:p>
<text:p text:style-name="Heading">{{reminder.name}}</text:p>
<text:p>Zu unserer Forderung „{{claim.title}}“ ist weiterhin ein Betrag von {{claim.balance}} offen.</text:p>
<text:p>Bitte begleiche den offenen Betrag innerhalb von {{reminder.payment_deadline_days}} Tagen.</text:p>
<text:p>Mahngebühr: {{reminder.fee}}</text:p>
<text:p>{{reminder.detail}}</text:p>
<text:p>Forderungs-ID: {{claim.id}}</text:p>
<text:p>Mahnstufe: {{reminder.level}}</text:p>
</office:text>
</office:body>
</office:document>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:mimetype="application/vnd.oasis.opendocument.text" office:version="1.3">
<office:styles>
<style:style style:name="Title" style:family="paragraph"><style:paragraph-properties fo:margin-bottom="0.4cm"/><style:text-properties fo:font-size="18pt" fo:font-weight="bold"/></style:style>
<style:style style:name="Heading" style:family="paragraph"><style:paragraph-properties fo:margin-top="0.35cm" fo:margin-bottom="0.15cm"/><style:text-properties fo:font-size="12pt" fo:font-weight="bold"/></style:style>
</office:styles>
<office:body>
<office:text>
<text:p text:style-name="Title">Chaos Computer Club Mannheim e.V.</text:p>
<text:p>Erstellt am {{document.date}}</text:p>
<text:p text:style-name="Heading">Mitgliedsdaten</text:p>
<text:p>Name: {{member.full_name}}</text:p>
<text:p>Mitgliedsnummer: {{member.number}}</text:p>
<text:p>E-Mail: {{member.email}}</text:p>
<text:p>Geburtsdatum: {{member.birth_date}}</text:p>
<text:p>Mitglied seit: {{member.started_at}}</text:p>
<text:p>Status: {{member.status}}</text:p>
</office:text>
</office:body>
</office:document>
+407
View File
@@ -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]
+48
View File
@@ -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,
+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"