feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
Check Cross-Plugin Imports / check (push) Has been cancelled

- Briefpapier (letterheads): Seiten-Setup (A4/A5/Letter, Ränder), Header/Footer-Blöcke, Wasserzeichen, Logo-Upload (DocumentAsset, data:-URI-only)
- Druckvorlagen (print_templates): Block-Komposition mit Briefpapier-Ref + entity_type
- Block-Registry (document_blocks.py): text/image/shape(line/rect/circle)/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via document_blocks()-Contract
- Renderer (document_renderer.py): Blocks→HTML→PDF via WeasyPrint (SSRF-Sandbox data:-URI-only), @page-Frame mit running header/footer, Placeholder-Beispiel-Defaults gegen StrictUndefined
- Contract-Beitrag contacts: document_placeholders/document_data (#359-Muster wie importexport_entities)
- 13 neue Endpoints in documents.py: Letterhead-CRUD, Template-CRUD, Assets, document-blocks, document-placeholders, preview (HTML), render (PDF)
- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path, RLS fail-closed crm_api)
- Frontend: api/documents.ts, Settings→Dokumente (settings_pages), BlockEditor (@dnd-kit Palette/Canvas/Config/Live-Preview-iframe), LetterheadEditor, PrintTemplateEditor, DocumentGenerationDialog (global, ContactDetailPage-Integration)
- i18n de/en, api-documentation.md, plugin-development-guide.md, PROGRESS.md

Verifikation: 32/32 neue Tests + 9/9 Regressionen, tsc exit 0, Build OK 2.79s, Alembic-Fresh-DB 0143 mit RLS bewiesen, ruff clean
This commit is contained in:
Agent Zero
2026-08-29 09:49:16 +02:00
parent fa429c3a88
commit b311ab7aa1
25 changed files with 4510 additions and 11 deletions
@@ -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)