Files
leocrm/app/plugins/builtins/report_generator/documents.py
T
Agent Zero b311ab7aa1
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
- 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
2026-08-29 09:49:16 +02:00

711 lines
25 KiB
Python

"""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}"'},
)