mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
feat: repeat claim items in document tables
This commit is contained in:
@@ -49,7 +49,7 @@ member-store/
|
|||||||
├── housekeeper.json
|
├── housekeeper.json
|
||||||
├── rules/
|
├── rules/
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── Forderung.fodt
|
│ ├── Forderung mit Positionen.fodt
|
||||||
│ ├── Mahnung.fodt
|
│ ├── Mahnung.fodt
|
||||||
│ └── Mitglied.fodt
|
│ └── Mitglied.fodt
|
||||||
└── members/
|
└── members/
|
||||||
@@ -97,6 +97,20 @@ available when a reminder row is selected before opening the document dialog.
|
|||||||
Unknown or unavailable placeholders stop generation with a clear error rather
|
Unknown or unavailable placeholders stop generation with a clear error rather
|
||||||
than producing an incomplete letter.
|
than producing an incomplete letter.
|
||||||
|
|
||||||
|
To repeat a formatted table row for every claim item, place both loop markers
|
||||||
|
inside the same template row:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{#claim.items}}
|
||||||
|
{{item.description}} | {{item.type}} | {{item.quantity}} | {{item.unit_price}} | {{item.amount}}
|
||||||
|
{{/claim.items}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The opening marker may share the first cell with its value and the closing
|
||||||
|
marker may share the last cell. CCMA removes both markers and clones the whole
|
||||||
|
row, including its formatting, once per item. With no items, the template row
|
||||||
|
is removed. A loop that is not closed in the same row is rejected.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"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.",
|
"OpenDocument-Templates mit Platzhaltern, lokaler PDF-Erzeugung, Audit-Verknüpfung und Dokumentöffnung aus der Mitgliederakte ergänzt.",
|
||||||
"Zentrale Vereins- und Absenderdaten sowie getrennte Mitgliedsbereiche für Anschrift, Telefon und validierte Bank-/SEPA-Daten ergänzt.",
|
"Zentrale Vereins- und Absenderdaten sowie getrennte Mitgliedsbereiche für Anschrift, Telefon und validierte Bank-/SEPA-Daten ergänzt.",
|
||||||
|
"Wiederholbare OpenDocument-Tabellenzeilen für beliebig viele Forderungspositionen eingeführt.",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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: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:table="urn:oasis:names:tc:opendocument:xmlns:table: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>
|
<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="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>
|
<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>
|
||||||
@@ -15,7 +15,22 @@
|
|||||||
<text:p>Anschrift: {{member.address_line}}</text:p>
|
<text:p>Anschrift: {{member.address_line}}</text:p>
|
||||||
<text:p>Fällig am: {{claim.due_date}}</text:p>
|
<text:p>Fällig am: {{claim.due_date}}</text:p>
|
||||||
<text:p text:style-name="Heading">Positionen</text:p>
|
<text:p text:style-name="Heading">Positionen</text:p>
|
||||||
<text:p>{{claim.items}}</text:p>
|
<table:table table:name="Forderungspositionen">
|
||||||
|
<table:table-row>
|
||||||
|
<table:table-cell><text:p>Beschreibung</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>Typ</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>Menge</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>Einzelpreis</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>Betrag</text:p></table:table-cell>
|
||||||
|
</table:table-row>
|
||||||
|
<table:table-row>
|
||||||
|
<table:table-cell><text:p>{{#claim.items}}{{item.description}}</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>{{item.type}}</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>{{item.quantity}}</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>{{item.unit_price}} EUR</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>{{item.amount}} EUR{{/claim.items}}</text:p></table:table-cell>
|
||||||
|
</table:table-row>
|
||||||
|
</table:table>
|
||||||
<text:p>Gesamtbetrag: {{claim.total}}</text:p>
|
<text:p>Gesamtbetrag: {{claim.total}}</text:p>
|
||||||
<text:p>Bereits bezahlt: {{claim.paid}}</text:p>
|
<text:p>Bereits bezahlt: {{claim.paid}}</text:p>
|
||||||
<text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</text:p>
|
<text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</text:p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -27,6 +28,7 @@ from ccma.storage.repository import MemberRepository
|
|||||||
|
|
||||||
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
|
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
|
||||||
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
|
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
|
||||||
|
REPEAT_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
class DocumentError(RuntimeError):
|
class DocumentError(RuntimeError):
|
||||||
@@ -119,7 +121,7 @@ class DocumentService:
|
|||||||
organization = self.repository.get_configuration().get("organization") or {}
|
organization = self.repository.get_configuration().get("organization") or {}
|
||||||
if not isinstance(organization, dict):
|
if not isinstance(organization, dict):
|
||||||
organization = {}
|
organization = {}
|
||||||
values = _template_values(
|
values, repeats = _template_values(
|
||||||
member,
|
member,
|
||||||
data=data,
|
data=data,
|
||||||
claim=claim,
|
claim=claim,
|
||||||
@@ -133,7 +135,7 @@ class DocumentService:
|
|||||||
with tempfile.TemporaryDirectory(prefix="ccma-document-") as temporary_name:
|
with tempfile.TemporaryDirectory(prefix="ccma-document-") as temporary_name:
|
||||||
temporary = Path(temporary_name)
|
temporary = Path(temporary_name)
|
||||||
rendered = temporary / f"rendered{template_path.suffix.casefold()}"
|
rendered = temporary / f"rendered{template_path.suffix.casefold()}"
|
||||||
_render_template(template_path, rendered, values)
|
_render_template(template_path, rendered, values, repeats)
|
||||||
converted = _convert_to_pdf(rendered, temporary)
|
converted = _convert_to_pdf(rendered, temporary)
|
||||||
temporary_destination = destination.with_name(f".{destination.name}.tmp")
|
temporary_destination = destination.with_name(f".{destination.name}.tmp")
|
||||||
try:
|
try:
|
||||||
@@ -178,7 +180,7 @@ def _template_values(
|
|||||||
claim: dict | None = None,
|
claim: dict | None = None,
|
||||||
reminder: dict | None = None,
|
reminder: dict | None = None,
|
||||||
organization: dict | None = None,
|
organization: dict | None = None,
|
||||||
) -> dict[str, str]:
|
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
|
||||||
organization = organization or {}
|
organization = organization or {}
|
||||||
organization_address = " ".join(
|
organization_address = " ".join(
|
||||||
part
|
part
|
||||||
@@ -231,6 +233,7 @@ def _template_values(
|
|||||||
"organization.bic": str(organization.get("bic", "")),
|
"organization.bic": str(organization.get("bic", "")),
|
||||||
"organization.creditor_id": str(organization.get("creditor_id", "")),
|
"organization.creditor_id": str(organization.get("creditor_id", "")),
|
||||||
}
|
}
|
||||||
|
repeats: dict[str, list[dict[str, str]]] = {}
|
||||||
if claim is not None and data is not None:
|
if claim is not None and data is not None:
|
||||||
claim_id = str(claim.get("claim_id", ""))
|
claim_id = str(claim.get("claim_id", ""))
|
||||||
item_lines = [
|
item_lines = [
|
||||||
@@ -250,6 +253,25 @@ def _template_values(
|
|||||||
"claim.items": "; ".join(item_lines),
|
"claim.items": "; ".join(item_lines),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
item_type_labels = {
|
||||||
|
"base": "Grundposition",
|
||||||
|
"product": "Produkt",
|
||||||
|
"service": "Dienstleistung",
|
||||||
|
"fee": "Gebühr",
|
||||||
|
"discount": "Rabatt",
|
||||||
|
"credit": "Gutschrift",
|
||||||
|
"correction": "Korrektur",
|
||||||
|
}
|
||||||
|
repeats["claim.items"] = [
|
||||||
|
{
|
||||||
|
"item.type": item_type_labels.get(str(item.get("type", "")), str(item.get("type", ""))),
|
||||||
|
"item.description": str(item.get("description", "")),
|
||||||
|
"item.quantity": str(item.get("quantity", "1")),
|
||||||
|
"item.unit_price": str(item.get("unit_price", item.get("amount", ""))),
|
||||||
|
"item.amount": str(item.get("amount", "")),
|
||||||
|
}
|
||||||
|
for item in claim_items(claim)
|
||||||
|
]
|
||||||
if reminder is not None:
|
if reminder is not None:
|
||||||
reminder_status_labels = {
|
reminder_status_labels = {
|
||||||
"draft": "Entwurf",
|
"draft": "Entwurf",
|
||||||
@@ -277,23 +299,28 @@ def _template_values(
|
|||||||
"reminder.channel": channel_labels.get(channel, channel),
|
"reminder.channel": channel_labels.get(channel, channel),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return values
|
return values, repeats
|
||||||
|
|
||||||
|
|
||||||
def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None:
|
def _render_template(
|
||||||
|
source: Path,
|
||||||
|
destination: Path,
|
||||||
|
values: dict[str, str],
|
||||||
|
repeats: dict[str, list[dict[str, str]]],
|
||||||
|
) -> None:
|
||||||
if source.suffix.casefold() == ".fodt":
|
if source.suffix.casefold() == ".fodt":
|
||||||
try:
|
try:
|
||||||
content = source.read_bytes()
|
content = source.read_bytes()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise DocumentError(f"Template konnte nicht gelesen werden: {exc}") from exc
|
raise DocumentError(f"Template konnte nicht gelesen werden: {exc}") from exc
|
||||||
destination.write_bytes(_replace_xml_placeholders(content, values))
|
destination.write_bytes(_replace_xml_placeholders(content, values, repeats))
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(source, "r") as archive, zipfile.ZipFile(destination, "w") as output:
|
with zipfile.ZipFile(source, "r") as archive, zipfile.ZipFile(destination, "w") as output:
|
||||||
for info in archive.infolist():
|
for info in archive.infolist():
|
||||||
content = archive.read(info.filename)
|
content = archive.read(info.filename)
|
||||||
if info.filename in {"content.xml", "styles.xml"}:
|
if info.filename in {"content.xml", "styles.xml"}:
|
||||||
content = _replace_xml_placeholders(content, values)
|
content = _replace_xml_placeholders(content, values, repeats)
|
||||||
output.writestr(info, content)
|
output.writestr(info, content)
|
||||||
except (OSError, zipfile.BadZipFile) as exc:
|
except (OSError, zipfile.BadZipFile) as exc:
|
||||||
raise DocumentError(f"OpenDocument-Template ist beschädigt: {exc}") from exc
|
raise DocumentError(f"OpenDocument-Template ist beschädigt: {exc}") from exc
|
||||||
@@ -322,14 +349,20 @@ def _template_fields(source: Path) -> set[str]:
|
|||||||
if _local_name(paragraph.tag) in {"p", "h"}:
|
if _local_name(paragraph.tag) in {"p", "h"}:
|
||||||
combined = "".join(value for _node, _attribute, value in _text_slots(paragraph))
|
combined = "".join(value for _node, _attribute, value in _text_slots(paragraph))
|
||||||
fields.update(match.group(1) for match in PLACEHOLDER_PATTERN.finditer(combined))
|
fields.update(match.group(1) for match in PLACEHOLDER_PATTERN.finditer(combined))
|
||||||
|
fields.update(match.group(2) for match in REPEAT_PATTERN.finditer(combined))
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def _replace_xml_placeholders(content: bytes, values: dict[str, str]) -> bytes:
|
def _replace_xml_placeholders(
|
||||||
|
content: bytes,
|
||||||
|
values: dict[str, str],
|
||||||
|
repeats: dict[str, list[dict[str, str]]] | None = None,
|
||||||
|
) -> bytes:
|
||||||
try:
|
try:
|
||||||
root = ElementTree.fromstring(content)
|
root = ElementTree.fromstring(content)
|
||||||
except ElementTree.ParseError as exc:
|
except ElementTree.ParseError as exc:
|
||||||
raise DocumentError(f"Template-XML ist beschädigt: {exc}") from exc
|
raise DocumentError(f"Template-XML ist beschädigt: {exc}") from exc
|
||||||
|
_expand_repeating_rows(root, repeats or {})
|
||||||
unknown: set[str] = set()
|
unknown: set[str] = set()
|
||||||
for paragraph in root.iter():
|
for paragraph in root.iter():
|
||||||
if _local_name(paragraph.tag) not in {"p", "h"}:
|
if _local_name(paragraph.tag) not in {"p", "h"}:
|
||||||
@@ -345,6 +378,38 @@ def _replace_xml_placeholders(content: bytes, values: dict[str, str]) -> bytes:
|
|||||||
return ElementTree.tostring(root, encoding="utf-8", xml_declaration=True)
|
return ElementTree.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_repeating_rows(root, repeats: dict[str, list[dict[str, str]]]) -> None:
|
||||||
|
for parent in root.iter():
|
||||||
|
for index, row in reversed(list(enumerate(list(parent)))):
|
||||||
|
if _local_name(row.tag) != "table-row":
|
||||||
|
continue
|
||||||
|
slots = _text_slots(row)
|
||||||
|
combined = "".join(value for _node, _attribute, value in slots)
|
||||||
|
markers = list(REPEAT_PATTERN.finditer(combined))
|
||||||
|
starts = [match for match in markers if match.group(1) == "#"]
|
||||||
|
if not starts:
|
||||||
|
continue
|
||||||
|
if len(starts) != 1:
|
||||||
|
raise DocumentError("Eine Tabellenzeile darf genau eine Wiederholung öffnen.")
|
||||||
|
section = starts[0].group(2)
|
||||||
|
if not any(match.group(1) == "/" and match.group(2) == section for match in markers):
|
||||||
|
raise DocumentError(f"Wiederholung {section} wird in derselben Tabellenzeile nicht beendet.")
|
||||||
|
if section not in repeats:
|
||||||
|
raise DocumentError(f"Wiederholung ist im aktuellen Kontext nicht verfügbar: {section}")
|
||||||
|
parent.remove(row)
|
||||||
|
for offset, item_values in enumerate(repeats[section]):
|
||||||
|
clone = copy.deepcopy(row)
|
||||||
|
clone_slots = _text_slots(clone)
|
||||||
|
clone_text = "".join(value for _node, _attribute, value in clone_slots)
|
||||||
|
clone_markers = list(REPEAT_PATTERN.finditer(clone_text))
|
||||||
|
_replace_matches(clone_slots, clone_markers, {section: ""}, key_group=2)
|
||||||
|
clone_slots = _text_slots(clone)
|
||||||
|
clone_text = "".join(value for _node, _attribute, value in clone_slots)
|
||||||
|
item_matches = list(PLACEHOLDER_PATTERN.finditer(clone_text))
|
||||||
|
_replace_matches(clone_slots, item_matches, item_values)
|
||||||
|
parent.insert(index + offset, clone)
|
||||||
|
|
||||||
|
|
||||||
def _text_slots(element) -> list[tuple[object, str, str]]:
|
def _text_slots(element) -> list[tuple[object, str, str]]:
|
||||||
slots: list[tuple[object, str, str]] = []
|
slots: list[tuple[object, str, str]] = []
|
||||||
|
|
||||||
@@ -360,14 +425,14 @@ def _text_slots(element) -> list[tuple[object, str, str]]:
|
|||||||
return slots
|
return slots
|
||||||
|
|
||||||
|
|
||||||
def _replace_matches(slots, matches, values: dict[str, str]) -> None:
|
def _replace_matches(slots, matches, values: dict[str, str], *, key_group: int = 1) -> None:
|
||||||
boundaries: list[tuple[int, int]] = []
|
boundaries: list[tuple[int, int]] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
for _element, _attribute, text in slots:
|
for _element, _attribute, text in slots:
|
||||||
boundaries.append((offset, offset + len(text)))
|
boundaries.append((offset, offset + len(text)))
|
||||||
offset += len(text)
|
offset += len(text)
|
||||||
for match in reversed(matches):
|
for match in reversed(matches):
|
||||||
key = match.group(1)
|
key = match.group(key_group)
|
||||||
if key not in values:
|
if key not in values:
|
||||||
continue
|
continue
|
||||||
start_index, start_offset = _slot_at(boundaries, match.start())
|
start_index, start_offset = _slot_at(boundaries, match.start())
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ class MemberRepository:
|
|||||||
builtin_templates = Path(__file__).resolve().parent.parent / "assets" / "templates"
|
builtin_templates = Path(__file__).resolve().parent.parent / "assets" / "templates"
|
||||||
if builtin_templates.is_dir():
|
if builtin_templates.is_dir():
|
||||||
for source in builtin_templates.iterdir():
|
for source in builtin_templates.iterdir():
|
||||||
destination = templates_root / source.name
|
destination_name = (
|
||||||
|
"Forderung mit Positionen.fodt"
|
||||||
|
if source.name == "Forderung.fodt"
|
||||||
|
else source.name
|
||||||
|
)
|
||||||
|
destination = templates_root / destination_name
|
||||||
if source.is_file() and not destination.exists():
|
if source.is_file() and not destination.exists():
|
||||||
shutil.copyfile(source, destination)
|
shutil.copyfile(source, destination)
|
||||||
config_path = self.root / "repository.json"
|
config_path = self.root / "repository.json"
|
||||||
|
|||||||
+43
-3
@@ -32,6 +32,42 @@ def test_unknown_placeholder_is_reported() -> None:
|
|||||||
_replace_xml_placeholders(source, {})
|
_replace_xml_placeholders(source, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_item_loop_clones_formatted_table_row() -> None:
|
||||||
|
source = b"""<office:document
|
||||||
|
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||||
|
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||||
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||||
|
<office:body><office:text><table:table>
|
||||||
|
<table:table-row table:style-name="item-row">
|
||||||
|
<table:table-cell><text:p>{{#claim.items}}{{item.description}}</text:p></table:table-cell>
|
||||||
|
<table:table-cell><text:p>{{item.amount}}{{/claim.items}}</text:p></table:table-cell>
|
||||||
|
</table:table-row>
|
||||||
|
</table:table></office:text></office:body></office:document>"""
|
||||||
|
items = [
|
||||||
|
{"item.description": "Beitrag", "item.amount": "75.00"},
|
||||||
|
{"item.description": "Gebühr", "item.amount": "5.00"},
|
||||||
|
]
|
||||||
|
|
||||||
|
rendered = _replace_xml_placeholders(source, {}, {"claim.items": items})
|
||||||
|
root = ElementTree.fromstring(rendered)
|
||||||
|
rows = [element for element in root.iter() if element.tag.endswith("table-row")]
|
||||||
|
row_text = ["".join("".join(row.itertext()).split()) for row in rows]
|
||||||
|
|
||||||
|
assert row_text == ["Beitrag75.00", "Gebühr5.00"]
|
||||||
|
assert all(next(iter(row.attrib.values())) == "item-row" for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_item_loop_removes_row_for_empty_collection() -> None:
|
||||||
|
source = b"""<table:table xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||||
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||||
|
<table:table-row><table:table-cell><text:p>{{#claim.items}}{{item.description}}
|
||||||
|
{{/claim.items}}</text:p></table:table-cell></table:table-row></table:table>"""
|
||||||
|
|
||||||
|
rendered = _replace_xml_placeholders(source, {}, {"claim.items": []})
|
||||||
|
|
||||||
|
assert not any(element.tag.endswith("table-row") for element in ElementTree.fromstring(rendered).iter())
|
||||||
|
|
||||||
|
|
||||||
def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path) -> None:
|
def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
repository.initialize()
|
||||||
@@ -42,7 +78,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
|
|||||||
|
|
||||||
assert template.read_text(encoding="utf-8") == "custom"
|
assert template.read_text(encoding="utf-8") == "custom"
|
||||||
assert {path.name for path in (repository.root / "templates").iterdir()} == {
|
assert {path.name for path in (repository.root / "templates").iterdir()} == {
|
||||||
"Forderung.fodt",
|
"Forderung mit Positionen.fodt",
|
||||||
"Mahnung.fodt",
|
"Mahnung.fodt",
|
||||||
"Mitglied.fodt",
|
"Mitglied.fodt",
|
||||||
}
|
}
|
||||||
@@ -64,8 +100,12 @@ def test_templates_are_filtered_by_available_context(tmp_path) -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert member_templates == {"Mitglied.fodt"}
|
assert member_templates == {"Mitglied.fodt"}
|
||||||
assert claim_templates == {"Forderung.fodt", "Mitglied.fodt"}
|
assert claim_templates == {"Forderung mit Positionen.fodt", "Mitglied.fodt"}
|
||||||
assert reminder_templates == {"Forderung.fodt", "Mahnung.fodt", "Mitglied.fodt"}
|
assert reminder_templates == {
|
||||||
|
"Forderung mit Positionen.fodt",
|
||||||
|
"Mahnung.fodt",
|
||||||
|
"Mitglied.fodt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypatch) -> None:
|
def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user