feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user