"""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 [])