mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
feat: add OpenDocument PDF templates
This commit is contained in:
@@ -9,6 +9,8 @@ directory containing `member.json`, `contributions.json`, an append-only
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
Requires Python 3.11+ with Tk support.
|
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
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
@@ -46,6 +48,10 @@ member-store/
|
|||||||
├── repository.json
|
├── repository.json
|
||||||
├── housekeeper.json
|
├── housekeeper.json
|
||||||
├── rules/
|
├── rules/
|
||||||
|
├── templates/
|
||||||
|
│ ├── Forderung.fodt
|
||||||
|
│ ├── Mahnung.fodt
|
||||||
|
│ └── Mitglied.fodt
|
||||||
└── members/
|
└── members/
|
||||||
└── <uuid>/
|
└── <uuid>/
|
||||||
├── member.json
|
├── member.json
|
||||||
@@ -54,6 +60,33 @@ member-store/
|
|||||||
└── files/
|
└── 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
|
## Housekeeper rules
|
||||||
|
|
||||||
The housekeeper runs every rule for every member. Built-in Python rules live in
|
The housekeeper runs every rule for every member. Built-in Python rules live in
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ ccma = [
|
|||||||
"VERSION",
|
"VERSION",
|
||||||
"assets/CHANGELOG.json",
|
"assets/CHANGELOG.json",
|
||||||
"assets/splash.png",
|
"assets/splash.png",
|
||||||
|
"assets/templates/*",
|
||||||
"assets/themes/forest/**/*",
|
"assets/themes/forest/**/*",
|
||||||
"assets/themes/forest/*",
|
"assets/themes/forest/*",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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]
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
@@ -88,6 +89,14 @@ class MemberRepository:
|
|||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
self.members_root.mkdir(parents=True, exist_ok=True)
|
self.members_root.mkdir(parents=True, exist_ok=True)
|
||||||
(self.root / "rules").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"
|
config_path = self.root / "repository.json"
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
||||||
@@ -541,6 +550,45 @@ class MemberRepository:
|
|||||||
references={"claim_id": claim_id, "reminder_id": reminder_id},
|
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(
|
def set_dunning_hold(
|
||||||
self,
|
self,
|
||||||
member_id: str,
|
member_id: str,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import tkinter as tk
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
from ccma.domain.contributions import (
|
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.domain.dates import date_input_hint, format_date_for_display
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||||
from ccma.ui.labels import (
|
from ccma.ui.labels import (
|
||||||
CLAIM_ITEM_TYPE_LABELS,
|
CLAIM_ITEM_TYPE_LABELS,
|
||||||
REMINDER_CHANNEL_LABELS,
|
REMINDER_CHANNEL_LABELS,
|
||||||
@@ -145,6 +147,9 @@ class ClaimTab(ttk.Frame):
|
|||||||
)
|
)
|
||||||
self.send_reminder_button.pack(side="right", padx=(8, 0))
|
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="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:
|
def refresh(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -307,6 +312,20 @@ class ClaimTab(ttk.Frame):
|
|||||||
def _add_reminder(self) -> None:
|
def _add_reminder(self) -> None:
|
||||||
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
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:
|
def _selected_reminder(self) -> dict | None:
|
||||||
selected = self.ledger.selection()
|
selected = self.ledger.selection()
|
||||||
if not selected or not selected[0].startswith("reminder:"):
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text
|
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 MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||||
from ccma.domain.models import Event
|
from ccma.domain.models import Event
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
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
|
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.columnconfigure(0, weight=1)
|
||||||
documents_tab.rowconfigure(1, weight=1)
|
documents_tab.rowconfigure(1, weight=1)
|
||||||
ttk.Button(documents_tab, text="Dateiordner öffnen", command=self._open_files).grid(
|
document_buttons = ttk.Frame(documents_tab)
|
||||||
row=0, column=0, sticky="w", pady=(0, 10)
|
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")
|
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:
|
def _build_timeline(self, parent: ttk.Frame) -> None:
|
||||||
parent.columnconfigure(0, weight=1)
|
parent.columnconfigure(0, weight=1)
|
||||||
@@ -244,11 +275,31 @@ class MemberTab(ttk.Frame):
|
|||||||
self.on_open_claim(self.member_id, selected[0])
|
self.on_open_claim(self.member_id, selected[0])
|
||||||
|
|
||||||
def _refresh_documents(self) -> None:
|
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"
|
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():
|
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:
|
def _save(self) -> None:
|
||||||
for key, variable in self.variables.items():
|
for key, variable in self.variables.items():
|
||||||
@@ -280,12 +331,31 @@ class MemberTab(ttk.Frame):
|
|||||||
|
|
||||||
def _open_files(self) -> None:
|
def _open_files(self) -> None:
|
||||||
path = self.repository.members_root / self.member_id / "files"
|
path = self.repository.members_root / self.member_id / "files"
|
||||||
if sys.platform == "win32":
|
self._open_path(path)
|
||||||
subprocess.Popen(["explorer", str(path)])
|
|
||||||
elif sys.platform == "darwin":
|
def _open_selected_document(self) -> None:
|
||||||
subprocess.Popen(["open", str(path)])
|
selected = self.documents.selection()
|
||||||
else:
|
if selected and selected[0] in self.document_paths:
|
||||||
subprocess.Popen(["xdg-open", str(path)])
|
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:
|
def _format_timestamp(event: Event) -> str:
|
||||||
@@ -299,3 +369,11 @@ def _event_label(event: Event) -> str:
|
|||||||
if event.actor_type == "system":
|
if event.actor_type == "system":
|
||||||
return f"[AUTO] {event.summary}"
|
return f"[AUTO] {event.summary}"
|
||||||
return 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"
|
||||||
|
|||||||
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||||
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||||
|
<office:body><office:text><text:p>Hello {{member.<text:span>full</text:span>_name}}!</text:p>
|
||||||
|
</office:text></office:body></office:document>"""
|
||||||
|
|
||||||
|
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"""<document><p>{{member.unknown}}</p></document>"""
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ from ccma.storage.repository import (
|
|||||||
def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path / "store")
|
repository = MemberRepository(tmp_path / "store")
|
||||||
repository.initialize()
|
repository.initialize()
|
||||||
|
assert (repository.root / "templates").is_dir()
|
||||||
|
|
||||||
member = repository.create_member(
|
member = repository.create_member(
|
||||||
first_name="Ada",
|
first_name="Ada",
|
||||||
|
|||||||
Reference in New Issue
Block a user