b311ab7aa1
Check Cross-Plugin Imports / check (push) Has been cancelled
- Briefpapier (letterheads): Seiten-Setup (A4/A5/Letter, Ränder), Header/Footer-Blöcke, Wasserzeichen, Logo-Upload (DocumentAsset, data:-URI-only) - Druckvorlagen (print_templates): Block-Komposition mit Briefpapier-Ref + entity_type - Block-Registry (document_blocks.py): text/image/shape(line/rect/circle)/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via document_blocks()-Contract - Renderer (document_renderer.py): Blocks→HTML→PDF via WeasyPrint (SSRF-Sandbox data:-URI-only), @page-Frame mit running header/footer, Placeholder-Beispiel-Defaults gegen StrictUndefined - Contract-Beitrag contacts: document_placeholders/document_data (#359-Muster wie importexport_entities) - 13 neue Endpoints in documents.py: Letterhead-CRUD, Template-CRUD, Assets, document-blocks, document-placeholders, preview (HTML), render (PDF) - Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path, RLS fail-closed crm_api) - Frontend: api/documents.ts, Settings→Dokumente (settings_pages), BlockEditor (@dnd-kit Palette/Canvas/Config/Live-Preview-iframe), LetterheadEditor, PrintTemplateEditor, DocumentGenerationDialog (global, ContactDetailPage-Integration) - i18n de/en, api-documentation.md, plugin-development-guide.md, PROGRESS.md Verifikation: 32/32 neue Tests + 9/9 Regressionen, tsc exit 0, Build OK 2.79s, Alembic-Fresh-DB 0143 mit RLS bewiesen, ruff clean
214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
"""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 [])
|