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