Files
leocrm/app/plugins/builtins/report_generator/document_blocks.py
T

214 lines
7.9 KiB
Python
Raw Normal View History

"""Document block registry — builtin block types, validation, contributions.
Phase L1: the central registry every drag/drop editor and the renderer use.
Builtin blocks cover text, image, simple graphics (shapes), tables, spacers
and placeholders. Modules can contribute additional palette blocks via the
contract hook ``document_blocks()`` (same philosophy as
``importexport_entities()``, #359). Contributed blocks declare ``fields``
(data keys) and render as a key-value table; report_generator owns the
generic rendering so modules never inject executable code.
A block is a plain dict: ``{"id": str, "type": str, "config": dict}``.
The ``id`` is editor-local (stable within one template/letterhead).
"""
from __future__ import annotations
from typing import Any
# ─── Builtin block metadata (palette + validation) ───────────────────────────
BUILTIN_BLOCKS: dict[str, dict[str, Any]] = {
"text": {
"label": "Text",
"category": "basis",
"description": "Absatz mit Jinja2-Platzhaltern ({{firstname}})",
"fields": {
"content": "str (Pflicht)",
"style": "dict? (fontSize, align, bold, italic, color)",
},
},
"image": {
"label": "Bild",
"category": "basis",
"description": "Bild aus Briefpapier-Assets (wird als data:-URI ins PDF eingebettet)",
"fields": {
"asset_id": "uuid?",
"url": "str? (data:-URI)",
"width": "int? (px)",
"height": "int? (px)",
"alt": "str?",
"align": "left|center|right?",
},
},
"shape": {
"label": "Grafik / Form",
"category": "grafik",
"description": "Einfache Grafik: Linie, Rechteck, Kreis",
"fields": {
"shape": "line|rect|circle (Pflicht)",
"width": "str? (CSS, z.B. 100% oder 120px)",
"height": "int? (px)",
"color": "str? (CSS-Farbe)",
"background": "str? (CSS-Farbe, rect/circle)",
"radius": "int? (%)",
},
},
"table": {
"label": "Tabelle",
"category": "basis",
"description": "Statische oder datengetriebene Tabelle",
"fields": {
"columns": "list[str]?",
"rows": "list[list]?",
"striped": "bool?",
"width": "str? (CSS)",
},
},
"spacer": {
"label": "Abstand",
"category": "layout",
"description": "Vertikaler Abstand",
"fields": {"height": "int? (px, Standard 24)"},
},
"divider": {
"label": "Trennlinie",
"category": "layout",
"description": "Horizontale Trennlinie",
"fields": {"color": "str?", "thickness": "int? (px)"},
},
"placeholder": {
"label": "Platzhalter",
"category": "daten",
"description": "Einzelner Daten-Platzhalter mit Label",
"fields": {"key": "str (Pflicht)", "label": "str?"},
},
"pagebreak": {
"label": "Seitenwechsel",
"category": "layout",
"description": "Erzwingt einen Seitenumbruch im PDF",
"fields": {},
},
}
VALID_SHAPES = {"line", "rect", "circle"}
class BlockValidationError(ValueError):
"""Raised when a block composition is invalid (→ HTTP 422)."""
def _module_contributions() -> list[tuple[str, dict[str, Any]]]:
"""Collect ``document_blocks()`` contributions from plugin contracts."""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
contributions: list[tuple[str, dict[str, Any]]] = []
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
blocks_fn = getattr(contract, "document_blocks", None)
if blocks_fn is None:
continue
try:
blocks = blocks_fn() or []
except Exception: # noqa: BLE001 — a broken contribution must not break the registry
continue
for block in blocks:
btype = block.get("type") if isinstance(block, dict) else None
if btype and btype not in BUILTIN_BLOCKS:
meta = dict(block)
meta.setdefault("category", "modul")
meta["contributed_by"] = plugin_name
contributions.append((btype, meta))
return contributions
def get_document_blocks() -> list[dict[str, Any]]:
"""Return all block types (builtin + module contributions) for the palette."""
result = [
{"type": btype, "label": meta["label"], "category": meta.get("category", "basis"), "description": meta.get("description", ""), "fields": meta.get("fields", {}), "builtin": True}
for btype, meta in BUILTIN_BLOCKS.items()
]
for btype, meta in _module_contributions():
result.append({
"type": btype,
"label": meta.get("label", btype),
"category": meta.get("category", "modul"),
"description": meta.get("description", ""),
"fields": meta.get("fields", {}),
"builtin": False,
"contributed_by": meta.get("contributed_by"),
})
return result
def _known_types() -> set[str]:
types = set(BUILTIN_BLOCKS.keys())
for btype, _meta in _module_contributions():
types.add(btype)
return types
def _contribution_meta(btype: str) -> dict[str, Any] | None:
for ctype, meta in _module_contributions():
if ctype == btype:
return meta
return None
def validate_block(block: Any, *, index: int = 0, known_types: set[str] | None = None) -> None:
"""Validate a single block dict. Raises BlockValidationError."""
if not isinstance(block, dict):
raise BlockValidationError(f"Block {index} ist kein Objekt")
btype = block.get("type")
if not btype or not isinstance(btype, str):
raise BlockValidationError(f"Block {index}: 'type' fehlt")
if known_types is None:
known_types = _known_types()
if btype not in known_types:
raise BlockValidationError(
f"Unbekannter Block-Typ '{btype}' (Block {index})"
)
config = block.get("config") or {}
if not isinstance(config, dict):
raise BlockValidationError(f"Block {index} ({btype}): 'config' muss ein Objekt sein")
if btype == "text":
content = config.get("content")
if not isinstance(content, str) or not content.strip():
raise BlockValidationError("text-Block benötigt ein nicht-leeres 'content'")
elif btype == "shape":
shape = config.get("shape")
if shape not in VALID_SHAPES:
raise BlockValidationError(
f"shape-Block: 'shape' muss eine von {sorted(VALID_SHAPES)} sein"
)
elif btype == "table":
columns = config.get("columns")
rows = config.get("rows")
if columns is not None and not isinstance(columns, list):
raise BlockValidationError("table-Block: 'columns' muss eine Liste sein")
if rows is not None and not isinstance(rows, list):
raise BlockValidationError("table-Block: 'rows' muss eine Liste sein")
elif btype == "placeholder":
key = config.get("key")
if not isinstance(key, str) or not key.strip():
raise BlockValidationError("placeholder-Block benötigt ein 'key'")
def validate_blocks(blocks: Any) -> None:
"""Validate a full block list. Raises BlockValidationError (→ 422)."""
if not isinstance(blocks, list):
raise BlockValidationError("'blocks' muss eine Liste sein")
known = _known_types()
for i, block in enumerate(blocks):
validate_block(block, index=i, known_types=known)
def contribution_fields(btype: str) -> list[str] | None:
"""Data keys a contributed block renders (generic key-value table)."""
meta = _contribution_meta(btype)
if meta is None:
return None
return list(meta.get("fields") or [])