feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
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
This commit is contained in:
Agent Zero
2026-08-29 09:49:16 +02:00
parent fa429c3a88
commit b311ab7aa1
25 changed files with 4510 additions and 11 deletions
+108
View File
@@ -308,6 +308,47 @@ class ContactsContract:
)
return _serialize_contact(contact)
# ─── Document Generator contribution (Phase L1, #359 pattern) ───
# The documents generator resolves placeholders + entity data via these
# contract hooks. Same philosophy as importexport_entities(): the module
# owns its domain data, the generic renderer stays module-agnostic.
@staticmethod
def document_entity_types() -> list[str]:
"""Entity types this plugin serves in the documents generator."""
return ["contact", "company", "person"]
@staticmethod
def document_placeholders(entity_type: str) -> list[dict]:
"""Placeholder descriptors (key/label/example) for the drag/drop editor."""
return _placeholders_for(entity_type)
@staticmethod
async def document_data(
db: AsyncSession,
tenant_id: Any,
entity_id: Any,
entity_type: str,
) -> dict[str, Any]:
"""Load one entity as template data ({} when not found)."""
contact = (
await db.execute(
select(Contact).where(
Contact.id == entity_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if contact is None:
return {}
fields = _contacts_document_fields()
data: dict[str, Any] = {}
for key in fields:
value = getattr(contact, key, None)
data[key] = value if value is not None else ""
return data
@classmethod
def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent."""
@@ -318,3 +359,70 @@ class ContactsContract:
_contract = ContactsContract()
get_contract_registry().register("contacts", _contract)
def _contacts_document_fields() -> dict[str, str]:
"""Contact/company fields available in document templates (L1).
Keys map to Contact model attributes; labels/examples feed the
drag/drop editor palette and the preview fallback values.
"""
return {
"displayname": "Anzeigename",
"firstname": "Vorname",
"surname": "Nachname",
"name": "Firmenname",
"email": "E-Mail",
"email_1": "E-Mail 1",
"email_2": "E-Mail 2",
"phone": "Telefon",
"phone_1": "Telefon 1",
"phone_2": "Telefon 2",
"mobile": "Mobil",
"website": "Website",
"industry": "Branche",
"city": "Stadt",
"postalcode": "PLZ",
"country": "Land",
"vat_code": "USt-IdNr.",
"function": "Funktion",
"department": "Abteilung",
}
_CONTACT_DOC_EXAMPLES = {
"displayname": "Max Mustermann",
"firstname": "Max",
"surname": "Mustermann",
"name": "Muster GmbH",
"email": "max@example.com",
"email_1": "max@example.com",
"email_2": "buero@example.com",
"phone": "+49 30 123456",
"phone_1": "+49 30 123456",
"phone_2": "+49 171 1234567",
"mobile": "+49 171 1234567",
"website": "https://example.com",
"industry": "IT",
"city": "Berlin",
"postalcode": "10115",
"country": "Deutschland",
"vat_code": "DE123456789",
"function": "Geschäftsführer",
"department": "Vertrieb",
}
def _placeholders_for(entity_type: str) -> list[dict]:
"""Placeholder descriptors for contact/company templates."""
if entity_type not in ("contact", "company", "person"):
return []
fields = _contacts_document_fields()
result = []
for key, label in fields.items():
result.append({
"key": key,
"label": label,
"example": _CONTACT_DOC_EXAMPLES.get(key, ""),
})
return result
@@ -0,0 +1,213 @@
"""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 [])
@@ -0,0 +1,386 @@
"""Document renderer — block composition → HTML → PDF (Phase L1-L3).
Owns the generic rendering for all block types. Modules contribute data and
metadata (placeholders, block descriptors) but never markup — the renderer
turns every block into HTML itself, which keeps the PDF surface sandboxed
(WeasyPrint URL fetcher allows data: URIs only).
Pipeline:
blocks + letterhead config + data
→ ``collect_placeholder_defaults`` fills missing data keys with the
module's example values (editor preview without entity)
→ ``render_blocks_html`` renders each block (Jinja2 for text content,
escaped; shapes/dividers as styled divs; images as data:-URI img)
→ ``render_document_html`` wraps content in the letterhead page frame
(@page geometry + running header/footer elements)
→ ``generate_pdf`` (pdf_generator) → bytes
"""
from __future__ import annotations
import base64
import html as _html
import re
import uuid
from typing import Any
from jinja2.sandbox import SandboxedEnvironment
# Page sizes in mm (CSS @page)
PAGE_SIZES = {
"A4": "210mm 297mm",
"A5": "148mm 210mm",
"letter": "8.5in 11in",
}
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}")
def _jinja_env() -> SandboxedEnvironment:
env = SandboxedEnvironment(autoescape=True, trim_blocks=True, lstrip_blocks=True)
return env
# ─── Placeholder defaults ───────────────────────────────────────────────────
def collect_placeholder_defaults(entity_type: str | None) -> dict[str, Any]:
"""Collect placeholder example values for an entity type.
Aggregates ``document_placeholders(entity_type)`` contributions from
all plugin contracts. Returns ``{key: example}`` for the editor preview
(rendering without live entity data must not raise).
"""
if not entity_type:
return {}
defaults: dict[str, Any] = {}
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "document_placeholders", None)
if fn is None:
continue
try:
placeholders = fn(entity_type) or []
except Exception: # noqa: BLE001
continue
for p in placeholders:
if isinstance(p, dict) and p.get("key"):
defaults[p["key"]] = p.get("example", "")
return defaults
def merge_placeholder_defaults(data: dict | None, entity_type: str | None) -> dict[str, Any]:
"""Overlay missing keys with placeholder examples (preview-safe data)."""
merged: dict[str, Any] = dict(data or {})
for key, example in collect_placeholder_defaults(entity_type).items():
if key not in merged or merged[key] in (None, ""):
merged[key] = example
return merged
# ─── Block → HTML ───────────────────────────────────────────────────────────
def _style_attr(style: dict | None) -> str:
"""Convert a small style dict into an inline style attribute."""
if not isinstance(style, dict):
return ""
allowed = {
"fontSize": "font-size",
"font-size": "font-size",
"color": "color",
"textAlign": "text-align",
"text-align": "text-align",
}
parts = []
if style.get("bold"):
parts.append("font-weight: bold")
if style.get("italic"):
parts.append("font-style: italic")
for k, v in style.items():
css = allowed.get(k)
if css and isinstance(v, (str, int, float)):
parts.append(f"{css}: {_html.escape(str(v))}")
return f' style="{"; ".join(parts)}"' if parts else ""
def _render_text(content: str, data: dict[str, Any]) -> str:
"""Render Jinja2 placeholders inside a text block (autoescaped)."""
try:
template = _jinja_env().from_string(content)
return template.render(**data)
except Exception: # noqa: BLE001 — a broken expression renders literally
return _html.escape(content)
def _img_url(config: dict, assets_map: dict[str, str]) -> str | None:
"""Resolve an image block to a data:-URI (sandbox policy for WeasyPrint)."""
url = config.get("url")
if isinstance(url, str) and url.startswith("data:"):
return url
asset_id = config.get("asset_id")
if asset_id:
data_url = assets_map.get(str(asset_id))
if data_url:
return data_url
return None
def render_block_html(block: dict, data: dict[str, Any], assets_map: dict[str, str] | None = None) -> str:
"""Render one block dict to HTML. Unknown types render nothing."""
assets_map = assets_map or {}
btype = block.get("type")
config = block.get("config") or {}
if btype == "text":
rendered = _render_text(str(config.get("content", "")), data)
return f'<p class="doc-block doc-text"{_style_attr(config.get("style"))}>{rendered}</p>'
if btype == "image":
url = _img_url(config, assets_map)
if not url:
return '<div class="doc-block doc-image-missing" data-missing="true"></div>'
dims = ""
if isinstance(config.get("width"), (int, float)):
dims += f' width="{int(config["width"])}"'
if isinstance(config.get("height"), (int, float)):
dims += f' height="{int(config["height"])}"'
alt = _html.escape(str(config.get("alt", "")))
align = config.get("align", "left")
return f'<div class="doc-block doc-image" style="text-align: {_html.escape(str(align))}"><img src="{url}" alt="{alt}"{dims} /></div>'
if btype == "shape":
shape = config.get("shape")
color = _html.escape(str(config.get("color", "#111827")))
background = _html.escape(str(config.get("background", "#e5e7eb")))
width = config.get("width", "100%")
height = int(config.get("height") or 2)
radius = int(config.get("radius") or 50)
if shape == "line":
return (f'<hr class="doc-block doc-shape" style="border: none; '
f'border-top: {height}px solid {color}; width: {_html.escape(str(width))}; margin: 8px 0;" />')
if shape == "rect":
return (f'<div class="doc-block doc-shape" style="width: {_html.escape(str(width))}; '
f'height: {height}px; background: {background}; border: 1px solid {color}; margin: 8px 0;"></div>')
if shape == "circle":
size = height if height > 4 else 40
return (f'<div class="doc-block doc-shape" style="width: {size}px; height: {size}px; '
f'background: {background}; border: 1px solid {color}; border-radius: {radius}%; margin: 8px 0;"></div>')
return ""
if btype == "divider":
color = _html.escape(str(config.get("color", "#d1d5db")))
thickness = int(config.get("thickness") or 1)
return f'<hr class="doc-block doc-divider" style="border: none; border-top: {thickness}px solid {color}; margin: 12px 0;" />'
if btype == "spacer":
height = int(config.get("height") or 24)
return f'<div class="doc-block doc-spacer" style="height: {height}px;"></div>'
if btype == "table":
columns = config.get("columns") or []
rows = config.get("rows") or []
striped = " doc-table-striped" if config.get("striped") else ""
head = ""
if columns:
head = "<thead><tr>" + "".join(f"<th>{_html.escape(str(c))}</th>" for c in columns) + "</tr></thead>"
body_rows = []
for row in rows:
if not isinstance(row, (list, tuple)):
row = [row]
cells = "".join(f"<td>{_render_text(str(c), data) if isinstance(c, str) else _html.escape(str(c))}</td>" for c in row)
body_rows.append(f"<tr>{cells}</tr>")
body = "<tbody>" + "".join(body_rows) + "</tbody>" if body_rows else ""
width_style = f' style="width: {_html.escape(str(config["width"]))}"' if config.get("width") else ""
return f'<table class="doc-block doc-table{striped}"{width_style}>{head}{body}</table>'
if btype == "placeholder":
key = str(config.get("key", ""))
label = config.get("label") or key
value = data.get(key, "")
return (f'<div class="doc-block doc-placeholder"><span class="doc-placeholder-label">'
f'{_html.escape(str(label))}:</span> <span class="doc-placeholder-value">'
f'{_html.escape(str(value if value is not None else ""))}</span></div>')
if btype == "pagebreak":
return '<div class="doc-block doc-pagebreak" style="break-after: page;"></div>'
# Module-contributed block: generic key-value table over declared fields
from app.plugins.builtins.report_generator.document_blocks import contribution_fields
fields = contribution_fields(btype) if btype else None
if fields:
rows = "".join(
f"<tr><th>{_html.escape(str(f))}</th><td>{_html.escape(str(data.get(f, '')))}</td></tr>"
for f in fields
)
return f'<table class="doc-block doc-contribution"><tbody>{rows}</tbody></table>'
return ""
def render_blocks_html(blocks: list[dict], data: dict[str, Any], assets_map: dict[str, str] | None = None) -> str:
"""Render a block list to a HTML fragment."""
return "\n".join(render_block_html(b, data, assets_map) for b in blocks if isinstance(b, dict))
# ─── Letterhead frame ───────────────────────────────────────────────────────
def _esc(value: Any) -> str:
return _html.escape(str(value))
def render_document_html(
blocks: list[dict],
data: dict[str, Any],
letterhead_config: dict | None = None,
assets_map: dict[str, str] | None = None,
) -> str:
"""Wrap rendered blocks in the letterhead page frame (full HTML doc)."""
config = letterhead_config or {}
page = config.get("page") or {}
size = page.get("size", "A4")
orientation = page.get("orientation", "portrait")
margins = page.get("margins") or {}
m_top = margins.get("top", 25)
m_right = margins.get("right", 20)
m_bottom = margins.get("bottom", 25)
m_left = margins.get("left", 20)
page_css = PAGE_SIZES.get(size, PAGE_SIZES["A4"])
if orientation == "landscape":
# swap width/height for landscape
w, h = page_css.split()
page_css = f"{h} {w}"
header = config.get("header") or {}
footer = config.get("footer") or {}
header_html = ""
footer_html = ""
extra_top = 0
extra_bottom = 0
if header.get("enabled"):
header_html = render_blocks_html(header.get("blocks") or [], data, assets_map)
extra_top = 20 # reserve space for the running header
if footer.get("enabled"):
footer_html = render_blocks_html(footer.get("blocks") or [], data, assets_map)
extra_bottom = 18
watermark = config.get("watermark") or {}
watermark_html = ""
if watermark.get("enabled"):
text = _esc(watermark.get("text", ""))
watermark_html = (
f'<div class="doc-watermark">{text}</div>'
)
content = render_blocks_html(blocks, data, assets_map)
header_css = ""
if header_html:
header_css = (
"#doc-header { position: running(header); }\n"
"@page { @top-center { content: element(header); } }\n"
)
footer_css = ""
if footer_html:
footer_css = (
"#doc-footer { position: running(footer); }\n"
"@page { @bottom-center { content: element(footer); } }\n"
)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
@page {{
size: {page_css};
margin: {int(m_top) + extra_top}mm {int(m_right)}mm {int(m_bottom) + extra_bottom}mm {int(m_left)}mm;
}}
body {{ font-family: 'Helvetica', 'Arial', sans-serif; font-size: 11pt; color: #111827; line-height: 1.5; }}
.doc-text {{ margin: 0 0 10px 0; white-space: pre-wrap; }}
.doc-table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
.doc-table th, .doc-table td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
.doc-table-striped tbody tr:nth-child(even) {{ background: #f9fafb; }}
.doc-placeholder-label {{ font-weight: 600; color: #374151; }}
.doc-placeholder {{ margin: 4px 0; }}
.doc-watermark {{ position: fixed; top: 45%; left: 0; right: 0; text-align: center; font-size: 48pt; color: rgba(107, 114, 128, 0.25); transform: rotate(-30deg); }}
{header_css}{footer_css}
</style>
</head>
<body>
{f'<div id="doc-header">{header_html}</div>' if header_html else ''}
{f'<div id="doc-footer">{footer_html}</div>' if footer_html else ''}
{watermark_html}
<div id="doc-content">{content}</div>
</body>
</html>"""
# ─── Assets ────────────────────────────────────────────────────────────────
async def load_assets_data_urls(
db,
tenant_id: uuid.UUID,
asset_ids: list[str] | None = None,
letterhead_id: str | None = None,
) -> dict[str, str]:
"""Load DocumentAssets and return ``{asset_id: data_url}``.
Images are embedded as data:-URIs because the WeasyPrint URL fetcher
blocks external resources (SSRF policy). Missing assets are skipped.
"""
from sqlalchemy import select
from app.plugins.builtins.report_generator.models import DocumentAsset
if not asset_ids and not letterhead_id:
return {}
q = select(DocumentAsset).where(
DocumentAsset.tenant_id == tenant_id,
DocumentAsset.deleted_at.is_(None),
)
if asset_ids:
try:
ids = [uuid.UUID(a) for a in asset_ids if a]
except (ValueError, TypeError):
ids = []
if not ids:
return {}
q = q.where(DocumentAsset.id.in_(ids))
elif letterhead_id:
try:
lh = uuid.UUID(letterhead_id)
except (ValueError, TypeError):
return {}
q = q.where(DocumentAsset.letterhead_id == lh)
from app.core.storage import get_storage_backend
assets = (await db.execute(q)).scalars().all()
storage = get_storage_backend()
result: dict[str, str] = {}
for asset in assets:
try:
content = await storage.read(asset.storage_path)
except Exception: # noqa: BLE001 — missing blob renders as empty
continue
b64 = base64.b64encode(content).decode("ascii")
result[str(asset.id)] = f"data:{asset.mime_type};base64,{b64}"
return result
def collect_block_asset_ids(blocks: list[dict]) -> list[str]:
"""Extract asset_id references from image blocks."""
ids: list[str] = []
for b in blocks or []:
if not isinstance(b, dict) or b.get("type") != "image":
continue
asset_id = (b.get("config") or {}).get("asset_id")
if isinstance(asset_id, str) and asset_id:
ids.append(asset_id)
return ids
@@ -0,0 +1,710 @@
"""Documents Generator routes (Phase L1-L3) — letterheads, print templates,
block registry, preview, render, assets.
Mounted under /api/v1/reports via the report_generator manifest (documents
endpoints live in their own module; the manifest registers it as a second
PluginRouteDef).
"""
from __future__ import annotations
import io
import uuid as uuid_mod
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db, set_tenant_context
from app.core.storage import get_storage_backend
from app.deps import require_permission
from app.plugins.builtins.report_generator.document_blocks import (
BlockValidationError,
get_document_blocks,
validate_blocks,
)
from app.plugins.builtins.report_generator.document_renderer import (
collect_block_asset_ids,
load_assets_data_urls,
merge_placeholder_defaults,
render_document_html,
)
from app.plugins.builtins.report_generator.models import (
DocumentAsset,
Letterhead,
PrintTemplate,
)
from app.plugins.builtins.report_generator.pdf_generator import generate_pdf
from app.plugins.builtins.report_generator.schemas import (
DocumentAssetResponse,
DocumentPreviewRequest,
DocumentPreviewResponse,
DocumentRenderRequest,
LetterheadCreate,
LetterheadResponse,
LetterheadUpdate,
PrintTemplateCreate,
PrintTemplateResponse,
PrintTemplateUpdate,
)
router = APIRouter(prefix="/api/v1/reports", tags=["documents"])
def _parse_uuid(val: str, field: str) -> uuid_mod.UUID:
try:
return uuid_mod.UUID(val)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
def _letterhead_to_response(lh: Letterhead) -> LetterheadResponse:
return LetterheadResponse(
id=str(lh.id),
name=lh.name,
description=lh.description,
config=lh.config or {},
is_default=lh.is_default,
created_by=str(lh.created_by),
created_at=lh.created_at,
updated_at=lh.updated_at,
)
def _template_to_response(t: PrintTemplate) -> PrintTemplateResponse:
return PrintTemplateResponse(
id=str(t.id),
name=t.name,
description=t.description,
letterhead_id=str(t.letterhead_id) if t.letterhead_id else None,
entity_type=t.entity_type,
blocks=t.blocks or [],
output_format=t.output_format,
created_by=str(t.created_by),
created_at=t.created_at,
updated_at=t.updated_at,
)
async def _load_entity_data(db, tenant_id, entity_type: str, entity_id):
"""Fetch document data for an entity via plugin contracts (L1).
Iterates contracts exposing ``document_data(db, tenant_id, entity_id,
entity_type)``; the first non-empty dict wins. Unknown entities → None
(→ 404); a known entity with no data → {} (renders empty placeholders).
"""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "document_data", None)
if fn is None:
continue
try:
data = await fn(db, tenant_id, entity_id, entity_type)
except Exception: # noqa: BLE001 — broken contribution must not 500
continue
if data:
return data
# No contribution produced data: unknown entity type or entity not
# found — both are 404 for the caller (never render an empty document
# silently).
return None
async def _get_letterhead(db, tenant_id, lh_id) -> Letterhead | None:
return (
await db.execute(
select(Letterhead).where(
Letterhead.id == lh_id,
Letterhead.tenant_id == tenant_id,
Letterhead.deleted_at.is_(None),
)
)
).scalar_one_or_none()
async def _get_template(db, tenant_id, tid) -> PrintTemplate | None:
return (
await db.execute(
select(PrintTemplate).where(
PrintTemplate.id == tid,
PrintTemplate.tenant_id == tenant_id,
PrintTemplate.deleted_at.is_(None),
)
)
).scalar_one_or_none()
# ─── Letterheads ─────────────────────────────────────────────────────────────
@router.get("/letterheads")
async def list_letterheads(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List letterheads for the current tenant."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
q = (
select(Letterhead)
.where(
Letterhead.tenant_id == tenant_id,
Letterhead.deleted_at.is_(None),
)
.order_by(Letterhead.name)
)
items = (await db.execute(q)).scalars().all()
return {
"items": [_letterhead_to_response(item).model_dump() for item in items],
"total": len(items),
}
@router.post("/letterheads", status_code=status.HTTP_201_CREATED)
async def create_letterhead(
body: LetterheadCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Create a letterhead."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
await set_tenant_context(db, tenant_id)
config = body.config or {}
for section in ("header", "footer"):
section_cfg = config.get(section) or {}
blocks = section_cfg.get("blocks")
if blocks is not None:
try:
validate_blocks(blocks)
except BlockValidationError as exc:
raise HTTPException(
422,
detail={"detail": str(exc), "code": "invalid_block"},
) from exc
lh = Letterhead(
tenant_id=tenant_id,
name=body.name,
description=body.description,
config=config,
is_default=body.is_default,
created_by=user_id,
owner_id=user_id,
)
db.add(lh)
await db.flush()
await log_audit(
db, tenant_id, user_id, "create", "letterhead", lh.id,
changes={"name": lh.name},
)
return _letterhead_to_response(lh).model_dump()
@router.get("/letterheads/{letterhead_id}")
async def get_letterhead(
letterhead_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
return _letterhead_to_response(lh).model_dump()
@router.put("/letterheads/{letterhead_id}")
async def update_letterhead(
letterhead_id: str,
body: LetterheadUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
if body.config is not None:
for section in ("header", "footer"):
section_cfg = (body.config or {}).get(section) or {}
blocks = section_cfg.get("blocks")
if blocks is not None:
try:
validate_blocks(blocks)
except BlockValidationError as exc:
raise HTTPException(
422,
detail={"detail": str(exc), "code": "invalid_block"},
) from exc
lh.config = body.config
if body.name is not None:
lh.name = body.name
if body.description is not None:
lh.description = body.description
if body.is_default is not None:
lh.is_default = body.is_default
await db.flush()
await db.refresh(lh) # onupdate columns expire — refresh async-safe
await log_audit(
db, tenant_id, user_id, "update", "letterhead", lh.id,
changes={"name": lh.name},
)
return _letterhead_to_response(lh).model_dump()
@router.delete("/letterheads/{letterhead_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_letterhead(
letterhead_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
from datetime import UTC, datetime
lh.deleted_at = datetime.now(UTC)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "letterhead", lh.id,
changes={"name": lh.name},
)
return None
# ─── Letterhead Assets (logo/image upload) ──────────────────────────────────
ALLOWED_IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"}
MAX_ASSET_SIZE = 5 * 1024 * 1024 # 5 MB
@router.post(
"/letterheads/{letterhead_id}/assets",
status_code=status.HTTP_201_CREATED,
)
async def upload_letterhead_asset(
letterhead_id: str,
file: UploadFile,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Upload an image asset for a letterhead (logo, header graphic)."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
mime = (file.content_type or "").lower()
if mime not in ALLOWED_IMAGE_MIMES:
raise HTTPException(
422,
detail={
"detail": f"Nur Bild-Dateien sind erlaubt (erhalten: {mime})",
"code": "invalid_mime_type",
},
)
content = await file.read()
if len(content) > MAX_ASSET_SIZE:
raise HTTPException(
413,
detail={"detail": "Bild ist größer als 5 MB", "code": "asset_too_large"},
)
asset_id = uuid_mod.uuid4()
storage = get_storage_backend()
storage_path = f"documents/{tenant_id}/{asset_id}"
await storage.save(storage_path, content)
asset = DocumentAsset(
id=asset_id,
tenant_id=tenant_id,
letterhead_id=lh_id,
filename=file.filename or "asset",
mime_type=mime,
size_bytes=len(content),
storage_path=storage_path,
created_by=user_id,
owner_id=user_id,
)
db.add(asset)
await db.flush()
await log_audit(
db, tenant_id, user_id, "create", "document_asset", asset.id,
changes={"filename": asset.filename, "letterhead_id": str(lh_id)},
)
import base64 as _b64
data_url = f"data:{mime};base64,{_b64.b64encode(content).decode('ascii')}"
return DocumentAssetResponse(
id=str(asset.id),
letterhead_id=str(asset.letterhead_id),
filename=asset.filename,
mime_type=asset.mime_type,
size_bytes=asset.size_bytes,
data_url=data_url,
created_at=asset.created_at,
).model_dump()
@router.get("/letterheads/{letterhead_id}/assets")
async def list_letterhead_assets(
letterhead_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List assets for a letterhead (metadata + data_url)."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
lh = await _get_letterhead(db, tenant_id, lh_id)
if lh is None:
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
assets_map = await load_assets_data_urls(db, tenant_id, letterhead_id=letterhead_id)
q = select(DocumentAsset).where(
DocumentAsset.tenant_id == tenant_id,
DocumentAsset.letterhead_id == lh_id,
DocumentAsset.deleted_at.is_(None),
)
assets = (await db.execute(q)).scalars().all()
return [
DocumentAssetResponse(
id=str(a.id),
letterhead_id=str(a.letterhead_id) if a.letterhead_id else None,
filename=a.filename,
mime_type=a.mime_type,
size_bytes=a.size_bytes,
data_url=assets_map.get(str(a.id)),
created_at=a.created_at,
).model_dump()
for a in assets
]
# ─── Print Templates ─────────────────────────────────────────────────────────
@router.get("/print-templates")
async def list_print_templates(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List print templates for the current tenant."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
q = (
select(PrintTemplate)
.where(
PrintTemplate.tenant_id == tenant_id,
PrintTemplate.deleted_at.is_(None),
)
.order_by(PrintTemplate.name)
)
items = (await db.execute(q)).scalars().all()
return {
"items": [_template_to_response(t).model_dump() for t in items],
"total": len(items),
}
@router.post("/print-templates", status_code=status.HTTP_201_CREATED)
async def create_print_template(
body: PrintTemplateCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Create a print template (drag/drop block composition)."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
await set_tenant_context(db, tenant_id)
try:
validate_blocks(body.blocks)
except BlockValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "invalid_block"}
) from exc
letterhead_id = None
if body.letterhead_id:
letterhead_id = _parse_uuid(body.letterhead_id, "letterhead_id")
if await _get_letterhead(db, tenant_id, letterhead_id) is None:
raise HTTPException(
404, detail={"detail": "Letterhead not found", "code": "not_found"}
)
template = PrintTemplate(
tenant_id=tenant_id,
name=body.name,
description=body.description,
letterhead_id=letterhead_id,
entity_type=body.entity_type,
blocks=body.blocks,
output_format=body.output_format,
created_by=user_id,
owner_id=user_id,
)
db.add(template)
await db.flush()
await log_audit(
db, tenant_id, user_id, "create", "print_template", template.id,
changes={"name": template.name},
)
return _template_to_response(template).model_dump()
@router.get("/print-templates/{template_id}")
async def get_print_template(
template_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
return _template_to_response(template).model_dump()
@router.put("/print-templates/{template_id}")
async def update_print_template(
template_id: str,
body: PrintTemplateUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
if body.blocks is not None:
try:
validate_blocks(body.blocks)
except BlockValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "invalid_block"}
) from exc
template.blocks = body.blocks
if body.name is not None:
template.name = body.name
if body.description is not None:
template.description = body.description
if body.entity_type is not None:
template.entity_type = body.entity_type
if body.output_format is not None:
template.output_format = body.output_format
if body.letterhead_id is not None:
if body.letterhead_id:
lh_id = _parse_uuid(body.letterhead_id, "letterhead_id")
if await _get_letterhead(db, tenant_id, lh_id) is None:
raise HTTPException(
404, detail={"detail": "Letterhead not found", "code": "not_found"}
)
template.letterhead_id = lh_id
else:
template.letterhead_id = None
await db.flush()
await db.refresh(template) # onupdate columns expire — refresh async-safe
await log_audit(
db, tenant_id, user_id, "update", "print_template", template.id,
changes={"name": template.name},
)
return _template_to_response(template).model_dump()
@router.delete("/print-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_print_template(
template_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
from datetime import UTC, datetime
template.deleted_at = datetime.now(UTC)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "print_template", template.id,
changes={"name": template.name},
)
return None
# ─── Block Registry / Placeholders ───────────────────────────────────────────
@router.get("/document-blocks")
async def list_document_blocks(
current_user: dict = Depends(require_permission("reports:read")),
):
"""List all available block types (builtin + module contributions)."""
return get_document_blocks()
@router.get("/document-placeholders")
async def list_document_placeholders(
entity_type: str | None = None,
current_user: dict = Depends(require_permission("reports:read")),
):
"""List available placeholders per entity type (module contributions)."""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
result: dict[str, list[dict]] = {}
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "document_placeholders", None)
if fn is None:
continue
try:
# contracts declare which entity types they serve
types_fn = getattr(contract, "document_entity_types", None)
entity_types = types_fn() if types_fn else ["contact"]
for etype in entity_types:
placeholders = fn(etype) or []
if placeholders:
result.setdefault(etype, []).extend(placeholders)
except Exception: # noqa: BLE001
continue
if entity_type:
return {entity_type: result.get(entity_type, [])}
return result
# ─── Preview (HTML) ──────────────────────────────────────────────────────────
@router.post("/documents/preview")
async def preview_document(
body: DocumentPreviewRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""Render blocks to HTML for the live editor preview (no PDF)."""
try:
validate_blocks(body.blocks)
except BlockValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "invalid_block"}
) from exc
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
# load assets referenced by image blocks (editor preview shows images)
asset_ids = collect_block_asset_ids(body.blocks)
header_blocks = ((body.letterhead_config or {}).get("header") or {}).get("blocks") or []
footer_blocks = ((body.letterhead_config or {}).get("footer") or {}).get("blocks") or []
asset_ids += collect_block_asset_ids(header_blocks)
asset_ids += collect_block_asset_ids(footer_blocks)
assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {}
data = merge_placeholder_defaults(body.data, body.entity_type)
html = render_document_html(
body.blocks,
data,
letterhead_config=body.letterhead_config,
assets_map=assets_map,
)
return DocumentPreviewResponse(html=html).model_dump()
# ─── Render (PDF) ────────────────────────────────────────────────────────────
@router.post("/print-templates/{template_id}/render")
async def render_print_template(
template_id: str,
body: DocumentRenderRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Render a stored print template with entity data to PDF."""
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
tid = _parse_uuid(template_id, "template_id")
template = await _get_template(db, tenant_id, tid)
if template is None:
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
entity_id = _parse_uuid(body.entity_id, "entity_id")
entity_data = await _load_entity_data(db, tenant_id, body.entity_type, entity_id)
if entity_data is None:
raise HTTPException(
404,
detail={
"detail": f"Kein Daten-Beitrag für entity_type '{body.entity_type}' — Modul nicht aktiv oder Entität unbekannt",
"code": "no_data_source",
},
)
# resolve letterhead
letterhead_config = None
letterhead_id = template.letterhead_id
if letterhead_id:
lh = await _get_letterhead(db, tenant_id, letterhead_id)
if lh:
letterhead_config = lh.config or {}
# load image assets from template blocks + letterhead blocks
asset_ids = collect_block_asset_ids(template.blocks or [])
if letterhead_config:
asset_ids += collect_block_asset_ids((letterhead_config.get("header") or {}).get("blocks") or [])
asset_ids += collect_block_asset_ids((letterhead_config.get("footer") or {}).get("blocks") or [])
assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {}
data = merge_placeholder_defaults(entity_data, template.entity_type)
html = render_document_html(
template.blocks or [],
data,
letterhead_config=letterhead_config,
assets_map=assets_map,
)
# sync PDF generation — close DB before CPU-bound work (existing pattern)
await db.close()
try:
pdf_bytes = generate_pdf(html)
except Exception as exc:
raise HTTPException(
500,
detail={"detail": f"PDF-Generierung fehlgeschlagen: {exc}", "code": "generation_failed"},
) from exc
from datetime import UTC, datetime
filename = f"{template.name.replace(' ', '_')}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@@ -0,0 +1,74 @@
-- Documents Generator (Phase L1-L3): letterheads, print_templates, document_assets
-- Dual-path safe: idempotent (IF NOT EXISTS); Alembic 0143 converges core installs.
CREATE TABLE IF NOT EXISTS letterheads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_default BOOLEAN NOT NULL DEFAULT false,
tenant_id UUID NOT NULL,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_letterheads_tenant ON letterheads(tenant_id);
CREATE INDEX IF NOT EXISTS ix_letterheads_name ON letterheads(name);
CREATE TABLE IF NOT EXISTS print_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
letterhead_id UUID REFERENCES letterheads(id) ON DELETE SET NULL,
entity_type VARCHAR(100) NOT NULL DEFAULT 'contact',
blocks JSONB NOT NULL DEFAULT '[]'::jsonb,
output_format VARCHAR(20) NOT NULL DEFAULT 'pdf',
tenant_id UUID NOT NULL,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_print_templates_tenant ON print_templates(tenant_id);
CREATE INDEX IF NOT EXISTS ix_print_templates_name ON print_templates(name);
CREATE TABLE IF NOT EXISTS document_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letterhead_id UUID REFERENCES letterheads(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
storage_path VARCHAR(1024) NOT NULL,
tenant_id UUID NOT NULL,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_document_assets_tenant ON document_assets(tenant_id);
CREATE INDEX IF NOT EXISTS ix_document_assets_letterhead ON document_assets(letterhead_id);
-- RLS fail-closed (matches migration 0084 pattern: FORCE + crm_api + USING/WITH CHECK)
DO $do$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY['letterheads', 'print_templates', 'document_assets'] LOOP
BEGIN
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', t || '_tenant_isolation', t);
EXECUTE format(
'CREATE POLICY %I ON %I AS PERMISSIVE FOR ALL TO crm_api USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
t || '_tenant_isolation', t
);
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'RLS setup skipped for %', t;
END;
END LOOP;
END
$do$;
@@ -4,7 +4,8 @@ from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, String, Text
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -63,3 +64,87 @@ class ReportInstance(Base, TenantMixin, OwnedMixin):
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class Letterhead(Base, TenantMixin, OwnedMixin):
"""Letterhead (Briefpapier) — page setup + header/footer block composition.
Phase L1: per-tenant letterhead. ``config`` stores the page geometry
(size/orientation/margins) plus header/footer/watermark block lists.
Blocks use the same ``{id, type, config}`` shape as print templates so
the drag/drop editor can edit both with one component set.
"""
__tablename__ = "letterheads"
__table_args__ = (
Index("ix_letterheads_tenant", "tenant_id"),
Index("ix_letterheads_name", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class PrintTemplate(Base, TenantMixin, OwnedMixin):
"""Print template — drag/drop block composition bound to a letterhead.
Phase L1: ``blocks`` is an ordered JSONB array of
``{id, type, config}`` entries validated against the document block
registry. ``entity_type`` selects the module placeholder contribution
(e.g. "company" → contacts placeholders).
"""
__tablename__ = "print_templates"
__table_args__ = (
Index("ix_print_templates_tenant", "tenant_id"),
Index("ix_print_templates_name", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("letterheads.id", ondelete="SET NULL"),
nullable=True,
)
entity_type: Mapped[str] = mapped_column(String(100), nullable=False, default="contact")
blocks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="pdf")
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class DocumentAsset(Base, TenantMixin, OwnedMixin):
"""Image asset for letterheads/print templates (logos, pictures).
Stored via the central storage backend; ``data_url`` is rendered into
PDFs inline (WeasyPrint URL fetcher allows data: URIs only).
"""
__tablename__ = "document_assets"
__table_args__ = (
Index("ix_document_assets_tenant", "tenant_id"),
Index("ix_document_assets_letterhead", "letterhead_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("letterheads.id", ondelete="CASCADE"),
nullable=True,
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
@@ -3,7 +3,13 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
PluginManifest,
PluginRouteDef,
)
class ReportGeneratorPlugin(BasePlugin):
@@ -17,12 +23,31 @@ class ReportGeneratorPlugin(BasePlugin):
is_core=True,
dependencies=["permissions"],
routes=[
# documents router MUST be registered before routes: its fixed
# single-segment paths (/letterheads, /print-templates, ...) would
# otherwise be shadowed by the /{report_id} catch-all in routes.
PluginRouteDef(
path="/api/v1/reports",
module="app.plugins.builtins.report_generator.documents",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/reports",
module="app.plugins.builtins.report_generator.routes",
router_attr="router",
),
],
settings_pages=[
FrontendSettingsPage(
path="documents",
label_key="settings.documents",
label="Dokumente",
component="@/pages/DocumentSettings",
icon="FileText",
order=75,
permission="reports:read",
),
],
events=["report.requested", "report.generated"],
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
@@ -38,8 +63,20 @@ class ReportGeneratorPlugin(BasePlugin):
contract_version="1.0.0")
def get_entity_models(self) -> dict[str, type]:
from app.plugins.builtins.report_generator.models import ReportInstance, ReportTemplate
return {"report_template": ReportTemplate, "report_instance": ReportInstance}
from app.plugins.builtins.report_generator.models import (
DocumentAsset,
Letterhead,
PrintTemplate,
ReportInstance,
ReportTemplate,
)
return {
"report_template": ReportTemplate,
"report_instance": ReportInstance,
"letterhead": Letterhead,
"print_template": PrintTemplate,
"document_asset": DocumentAsset,
}
async def on_activate(
self, db, service_container, event_bus
@@ -72,3 +72,93 @@ class ReportResponse(BaseModel):
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
# ─── Documents Generator (Phase L1-L3) ──────────────────────────────────────
class LetterheadCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
config: dict = Field(default_factory=dict)
is_default: bool = False
class LetterheadUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=2000)
config: dict | None = None
is_default: bool | None = None
class LetterheadResponse(BaseModel):
id: str
name: str
description: str
config: dict
is_default: bool
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
class PrintTemplateCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
letterhead_id: str | None = None
entity_type: str = Field("contact", max_length=100)
blocks: list[dict] = Field(default_factory=list)
output_format: str = Field("pdf", pattern="^(pdf|print)$")
class PrintTemplateUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=2000)
letterhead_id: str | None = None
entity_type: str | None = Field(None, max_length=100)
blocks: list[dict] | None = None
output_format: str | None = Field(None, pattern="^(pdf|print)$")
class PrintTemplateResponse(BaseModel):
id: str
name: str
description: str
letterhead_id: str | None = None
entity_type: str
blocks: list[dict]
output_format: str
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
class DocumentPreviewRequest(BaseModel):
"""Preview: render blocks to HTML (live preview in the editor)."""
blocks: list[dict]
letterhead_config: dict | None = None
entity_type: str | None = None
data: dict | None = None
class DocumentPreviewResponse(BaseModel):
html: str
class DocumentRenderRequest(BaseModel):
"""Render a stored print template with entity data to PDF."""
entity_type: str
entity_id: str
output_format: str = Field("pdf", pattern="^(pdf|print)$")
class DocumentAssetResponse(BaseModel):
id: str
letterhead_id: str | None = None
filename: str
mime_type: str
size_bytes: int
data_url: str | None = None
created_at: datetime | None = None