03dd477899
Check Cross-Plugin Imports / check (push) Has been cancelled
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry - Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M) - Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard - apply_entity_type_scope-Helper (requested ∧ scope) - Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder() - Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK - Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
967 lines
34 KiB
Python
967 lines
34 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 typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.ai.llm_client import llm_complete
|
|
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, require_workspace_scope
|
|
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,
|
|
collect_placeholder_defaults,
|
|
load_assets_data_urls,
|
|
merge_placeholder_defaults,
|
|
render_document_html,
|
|
)
|
|
from app.plugins.builtins.report_generator.einvoice import (
|
|
EInvoiceValidationError,
|
|
render_einvoice_xml,
|
|
resolve_einvoice_data,
|
|
validate_einvoice_data,
|
|
)
|
|
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,
|
|
DocumentSuggestRequest,
|
|
DocumentSuggestResponse,
|
|
EInvoiceRenderForRequest,
|
|
EInvoiceRenderRequest,
|
|
EInvoiceValidationResponse,
|
|
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")),
|
|
workspace_scope: dict | None = Depends(require_workspace_scope("reports")),
|
|
):
|
|
"""List print templates for the current tenant.
|
|
|
|
Phase N4: an active workspace scope restricts the template list to the
|
|
configured subset (pure AND — never a grant).
|
|
"""
|
|
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()
|
|
if workspace_scope:
|
|
from app.services.workspace_scope_service import scope_uuid_set
|
|
|
|
template_scope = scope_uuid_set(workspace_scope.get("template_ids"))
|
|
if template_scope is not None:
|
|
items = [t for t in items if t.id in template_scope]
|
|
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}"'},
|
|
)
|
|
|
|
|
|
# ─── E-Invoice (EN16931/XRechnung format layer, Phase L5) ────────────────────
|
|
|
|
|
|
@router.post("/einvoice/render")
|
|
async def render_einvoice(
|
|
body: EInvoiceRenderRequest,
|
|
current_user: dict = Depends(require_permission("reports:generate")),
|
|
):
|
|
"""Render inline invoice data to EN16931/XRechnung CII XML.
|
|
|
|
Pure format endpoint - the sales module later uses render-for with
|
|
its einvoice_data() contract hook.
|
|
"""
|
|
data = body.model_dump(exclude_none=True)
|
|
|
|
try:
|
|
validate_einvoice_data(data)
|
|
except EInvoiceValidationError as exc:
|
|
raise HTTPException(
|
|
422,
|
|
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
|
|
) from exc
|
|
|
|
xml = render_einvoice_xml(data)
|
|
filename = f"{data['invoice_number'].replace(' ', '_')}.xml"
|
|
return Response(
|
|
content=xml,
|
|
media_type="application/xml",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.post("/einvoice/validate")
|
|
async def validate_einvoice(
|
|
body: EInvoiceRenderRequest,
|
|
current_user: dict = Depends(require_permission("reports:read")),
|
|
):
|
|
"""Validate invoice data without rendering (missing BT/BG terms -> 422)."""
|
|
data = body.model_dump(exclude_none=True)
|
|
|
|
try:
|
|
validate_einvoice_data(data)
|
|
except EInvoiceValidationError as exc:
|
|
raise HTTPException(
|
|
422,
|
|
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
|
|
) from exc
|
|
return EInvoiceValidationResponse(valid=True).model_dump()
|
|
|
|
|
|
@router.post("/einvoice/render-for")
|
|
async def render_einvoice_for_entity(
|
|
body: EInvoiceRenderForRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("reports:generate")),
|
|
):
|
|
"""Render an e-invoice for an entity via the einvoice_data() contract.
|
|
|
|
Docking point for the future sales module: it contributes
|
|
einvoice_data(db, tenant_id, entity_id, entity_type) and this endpoint
|
|
handles validation + XML generation. No contribution -> 404.
|
|
"""
|
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
|
entity_id = _parse_uuid(body.entity_id, "entity_id")
|
|
|
|
data = await resolve_einvoice_data(db, tenant_id, body.entity_type, entity_id)
|
|
if data is None:
|
|
raise HTTPException(
|
|
404,
|
|
detail={
|
|
"detail": (
|
|
f"Kein E-Invoice-Datenbeitrag fuer entity_type '{body.entity_type}' - "
|
|
"Modul nicht aktiv oder Entitaet unbekannt"
|
|
),
|
|
"code": "no_data_source",
|
|
},
|
|
)
|
|
|
|
try:
|
|
validate_einvoice_data(data)
|
|
except EInvoiceValidationError as exc:
|
|
raise HTTPException(
|
|
422,
|
|
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
|
|
) from exc
|
|
|
|
xml = render_einvoice_xml(data)
|
|
invoice_number = str(data.get("invoice_number") or "einvoice")
|
|
filename = f"{invoice_number.replace(' ', '_')}.xml"
|
|
return Response(
|
|
content=xml,
|
|
media_type="application/xml",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
|
|
|
|
|
|
_SUGGEST_HEADER = (
|
|
"Du bist ein Assistent fuer einen Drag&Drop-Dokumenteditor fuer deutsche Geschaeftsdokumente.\n"
|
|
"Erstelle aus der Nutzeranfrage eine Block-Komposition fuer eine Druckvorlage.\n\n"
|
|
"Antworte AUSSCHLIESSLICH mit JSON in dieser Struktur:\n"
|
|
'{"blocks": [{"id": "b1", "type": "<block-type>", "config": {}}], "notes": "kurze Erklaerung"}\n\n'
|
|
"Verfuegbare Block-Typen mit ihren config-Feldern:\n"
|
|
)
|
|
|
|
_SUGGEST_MIDDLE = "\n\nVerfuegbare Platzhalter (in Text-Bloecken in der Form {key} verwendbar):\n"
|
|
|
|
_SUGGEST_FOOTER = (
|
|
"\n\nRegeln:\n"
|
|
"- Nutze nur gelistete Block-Typen.\n"
|
|
"- Jeder Block braucht eine eindeutige id (b1, b2, ...).\n"
|
|
"- Text-Inhalte koennen Jinja2-Platzhalter wie {firstname} enthalten.\n"
|
|
'- shape-Bloecke benoetigen "shape": "line", "rect" oder "circle".\n'
|
|
"- Antworte nur mit dem JSON-Objekt, kein Markdown, keine Code-Fences."
|
|
)
|
|
|
|
|
|
def _strip_code_fences(text: str) -> str:
|
|
"""Strip ```json ...``` fences LLMs like to add."""
|
|
stripped = text.strip()
|
|
if stripped.startswith("```"):
|
|
first_newline = stripped.find("\n")
|
|
if first_newline != -1:
|
|
stripped = stripped[first_newline + 1 :]
|
|
if stripped.rstrip().endswith("```"):
|
|
stripped = stripped.rstrip()[:-3]
|
|
return stripped.strip()
|
|
|
|
|
|
def _sanitize_suggested_blocks(blocks: Any) -> list[dict]:
|
|
"""Filter AI blocks down to registry-valid entries with server ids."""
|
|
if not isinstance(blocks, list):
|
|
return []
|
|
from app.plugins.builtins.report_generator.document_blocks import (
|
|
BlockValidationError,
|
|
_known_types,
|
|
validate_block,
|
|
)
|
|
|
|
known = _known_types()
|
|
result: list[dict] = []
|
|
for i, block in enumerate(blocks):
|
|
if not isinstance(block, dict):
|
|
continue
|
|
candidate = {
|
|
"id": str(block.get("id") or f"ai_{uuid_mod.uuid4().hex[:12]}"),
|
|
"type": block.get("type"),
|
|
"config": block.get("config") or {},
|
|
}
|
|
try:
|
|
validate_block(candidate, index=i, known_types=known)
|
|
except BlockValidationError:
|
|
continue # drop invalid AI output instead of failing the request
|
|
result.append(candidate)
|
|
return result
|
|
|
|
|
|
@router.post("/documents/suggest")
|
|
async def suggest_document_blocks(
|
|
body: DocumentSuggestRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
|
):
|
|
"""KI-Steuerung (L4): natural language -> block composition suggestion.
|
|
|
|
Goes through the central llm_complete (cost tracking, tenant budget).
|
|
The AI response is sanitized against the block registry - invalid
|
|
blocks are dropped, ids are assigned server-side. LLM failures -> 502.
|
|
"""
|
|
import json as _json
|
|
|
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
|
|
|
block_types = get_document_blocks()
|
|
block_types_desc = _json.dumps(
|
|
[
|
|
{"type": b["type"], "label": b["label"], "fields": b.get("fields", {})}
|
|
for b in block_types
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
placeholders = collect_placeholder_defaults(body.entity_type) if body.entity_type else {}
|
|
placeholders_desc = _json.dumps(placeholders, ensure_ascii=False)
|
|
|
|
system_prompt = (
|
|
_SUGGEST_HEADER + block_types_desc + _SUGGEST_MIDDLE + placeholders_desc + _SUGGEST_FOOTER
|
|
)
|
|
|
|
try:
|
|
result = await llm_complete(
|
|
model="gpt-4o-mini",
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": body.prompt},
|
|
],
|
|
temperature=0.3,
|
|
max_tokens=2000,
|
|
tenant_id=tenant_id,
|
|
db=db,
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
502,
|
|
detail={"detail": f"KI-Antwort fehlgeschlagen: {exc}", "code": "ai_unavailable"},
|
|
) from exc
|
|
|
|
raw_content = result.get("content") or ""
|
|
try:
|
|
parsed = _json.loads(_strip_code_fences(raw_content))
|
|
except (ValueError, TypeError) as exc:
|
|
raise HTTPException(
|
|
502,
|
|
detail={
|
|
"detail": "KI-Antwort war kein valides JSON",
|
|
"code": "invalid_ai_response",
|
|
},
|
|
) from exc
|
|
|
|
if not isinstance(parsed, dict):
|
|
raise HTTPException(
|
|
502,
|
|
detail={"detail": "KI-Antwort-Struktur ungueltig", "code": "invalid_ai_response"},
|
|
)
|
|
|
|
blocks = _sanitize_suggested_blocks(parsed.get("blocks"))
|
|
notes = str(parsed.get("notes") or "")
|
|
return DocumentSuggestResponse(blocks=blocks, notes=notes).model_dump()
|