From b311ab7aa16d2625f8e03a452633c10354818085 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 29 Aug 2026 09:49:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(L1-L3):=20Dokumente-Generator=20=E2=80=94?= =?UTF-8?q?=20Briefpapier+Block-System+Drag&Drop-Editor+Renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PROGRESS.md | 27 +- alembic/versions/0143_documents_generator.py | 115 +++ app/plugins/builtins/contacts/contracts.py | 108 +++ .../report_generator/document_blocks.py | 213 +++++ .../report_generator/document_renderer.py | 386 +++++++++ .../builtins/report_generator/documents.py | 710 ++++++++++++++++ .../migrations/0003_documents_generator.sql | 74 ++ .../builtins/report_generator/models.py | 87 +- .../builtins/report_generator/plugin.py | 43 +- .../builtins/report_generator/schemas.py | 90 +++ docs/api-documentation.md | 9 + docs/plugin-development-guide.md | 38 + frontend/src/api/documents.ts | 296 +++++++ .../src/components/documents/BlockEditor.tsx | 530 ++++++++++++ .../documents/DocumentGenerationDialog.tsx | 131 +++ .../components/documents/LetterheadEditor.tsx | 291 +++++++ .../documents/PrintTemplateEditor.tsx | 165 ++++ .../src/components/plugins/PluginLoader.tsx | 1 + frontend/src/i18n/locales/de.json | 96 ++- frontend/src/i18n/locales/en.json | 96 ++- frontend/src/pages/ContactDetailPage.tsx | 26 +- frontend/src/pages/DocumentSettings.tsx | 225 ++++++ frontend/src/pages/Settings.tsx | 3 +- frontend/src/routes/index.tsx | 2 + tests/test_documents_generator.py | 759 ++++++++++++++++++ 25 files changed, 4510 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/0143_documents_generator.py create mode 100644 app/plugins/builtins/report_generator/document_blocks.py create mode 100644 app/plugins/builtins/report_generator/document_renderer.py create mode 100644 app/plugins/builtins/report_generator/documents.py create mode 100644 app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql create mode 100644 frontend/src/api/documents.ts create mode 100644 frontend/src/components/documents/BlockEditor.tsx create mode 100644 frontend/src/components/documents/DocumentGenerationDialog.tsx create mode 100644 frontend/src/components/documents/LetterheadEditor.tsx create mode 100644 frontend/src/components/documents/PrintTemplateEditor.tsx create mode 100644 frontend/src/pages/DocumentSettings.tsx create mode 100644 tests/test_documents_generator.py diff --git a/PROGRESS.md b/PROGRESS.md index 13e7c7b..2736964 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,11 +12,10 @@ 4. **custom_field_definitions generisch machen** — ✅ erledigt (2026-08-29): W4b-Muster komplett angewendet. Route: 422-Entity-Validierung + 403-Owner-Modul-Check (contacts:read etc.) auf create/list/update/delete, ACL-Loch gefixt (delete übergab user_id nie → 500 für alle Nicht-Admins), PermissionError→403-Handler. Shape-Fix: Backend lieferte Array, alle 6 Frontend-Consumer lesen `data.items` → CustomFields-UI zeigte seit jeher leer; jetzt `{items,total}` (CustomFieldDefinitionListResponse). entity_permission_service: Plural-Ableitungs-Fix ('workflow'→workflows:read statt Phantom contacts:read; 'address'→addresses:read via +es), unregister_entity_model räumt ENTITY_PLUGIN_OWNERS mit auf (Lifecycle-Leak), zentrale Helper validate_entity_type/check_entity_read_permission — saved_filters+saved_views Duplikate entfernt (Aliase, Call-Sites unverändert). Neue Suite tests/test_custom_field_definitions.py **13 Tests**: Rot bewiesen 10 failed/4 passed → Grün **25 passed** (13 cfd + 12 saved_filters-Regression), Permission-Suiten 22/22, custom_fields+lifecycle 13/13, ruff exit=0, create_app OK. 3er-Kombi-Failures (custom_fields+entity_registry+lifecycle) per Stash als identischer Vorbestand bewiesen (clean HEAD: gleiche 7 Failures — Suite-Isolation, kein Zusammenhang mit Änderung). api-documentation.md ergänzt (4 Endpoints) 5. **Sidebar /contacts statische Route entfernen** — ✅ erledigt (2026-08-29): Kritikpunkt 21 zuerst erfüllt (Renderer bewiesen, DANN entfernt). PluginRouteRenderer komplett neu: verschachtelte `` statt manueller find()-Logik — vorher bewiesen kaputt: ':id'-Patterns konnten NIE matchen (Detail-Deep-Link /contacts/abc hätte die Liste gerendert), '/contacts/dedup' wäre auf die Liste gefallen, useParams() blieb im catch-all leer. Rot 4 failed → **Grün 9/9 Tests** (inkl. :id-Match, useParams={"id":"abc-123"}-Beweis, dedup-schlägt-:id-Spezifität). STATIC_COMPONENT_MAP um 3 Contacts-Seiten ergänzt (ARCH-019: Production-Build kann Runtime-Fallback nicht laden; Named-Exports explizit, Pages haben kein default). Statische Routen /contacts, /contacts/:id, /contacts/dedup aus index.tsx entfernt + 3 ungenutzte lazy-Imports; /trash (contacts:read) und /guest/contacts unberührt. Gates: tsc OK · **Production-Build BUILD_EXIT=0 (2.76s) mit frischen Chunks ContactsList-DZ-WOyL2.js/ContactDetailPage-LZBUypcB.js/DedupMerge-H6U7AYKS.js (02:04)** · Renderer 9/9 · routePermissions 6/6 · Router 2/2 · Sidebar-Nav-Quelle bewiesen: Sidebar.tsx Z.97 flatMap menu_items (Manifest, unberührt). AppShell-Solo/Combo-Worker-Crash = dokumentierter Vorbestand (heute 3× vor Änderung reproduziert) 6. **Kontakt-Model ins ContactsPlugin** — ✅ erledigt (2026-08-29): Contact/ContactPerson (258 Z.) physisch nach `app/plugins/builtins/contacts/models.py` (Mail-Vorbild). `app/models/contact.py` = PEP-562-Lazy-Re-Export-Brücke: alle 35 Import-Stellen (8 Core + 7 Plugin + 19 Tests + env.py) unverändert lauffähig; models/__init__.py Contact-Import lazy via Package-__getattr__. Rot-Lauf entlarvte ECHTEN Zirkel (conftest→core.auth→models→Shim→plugins→registry→cache→core.auth teilweise initialisiert → ImportError) — mit Lazy-__getattr__ bewiesen behoben, jedes Einstiegsschema zirkelfrei. TDD: Rot 6 failed → **Grün 9/9** (neue Suite test_contacts_model_ownership.py: Ownership, Shim-Identität, Alembic-Metadata-Integrität, Sync-Exclude, FK-Kette). sync_plugin_schema.py: ALEMBIC_OWNED_TABLES={contacts,contactpersons} verhindert Dual-Ownership (142 Alembic-Migrationen besitzen das Schema). Checker: Shim in EXEMPT_PATHS (dokumentierte Brücke), **0 Verstöße/483 Dateien**. Gates: create_app OK · ruff exit=0 · Regressionen: entity_registry 3/3, lifecycle 2/2, custom_fields 11/11, **auth solo 11/11** (Kombi-Failures = per Solo-Lauf bewiesener Isolations-Vorbestand) · **alembic upgrade head auf frischer DB OK** (0105 contacts_tsv_trigger recreated). Keine neue Migration, migration_hashes unberührt -7. **Phase L: Dokumente-Generator** — PLATFORM_ROADMAP.md 'Phase L' (L1-L5, ~9-15 Tage), user-abgestimmt, Basis: report_generator-Plugin **Wichtig:** AGENTS.md-Regeln zuerst lesen (§0.0 Sub-Agents nur für einfache Jobs, §0.2 auf bestehendem Code aufbauen, §10 'PROGRESS.md als Source of Truth'). -> **Letztes Update:** 2026-08-28 +> **Letztes Update:** 2026-08-29 ## Produktions-Bugfixes (2026-08-27) @@ -35,6 +34,30 @@ **Verifikation:** tests/test_custom_fields.py **11/11 passed** (Funktionserhalt) · create_app OK · ruff grün · Full Deploy SUCCESS · Health healthy +## Phase L1-L3 — Dokumente-Generator Backend+Editor (2026-08-29) ✅ + +**Scope:** Briefpapier (letterheads) + Druckvorlagen (print_templates) + Assets (document_assets) + Block-Registry + Contract-Beiträge + Drag&Drop-Editor + globaler Dokument-Dialog. Erweiterung des report_generator-Plugins (kein Neubau). + +**Umgesetzt:** +- Backend: `documents.py` (13 Endpoints), `document_blocks.py` (Registry: text/image/shape/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via `document_blocks()`-Contract), `document_renderer.py` (Blocks→HTML→PDF, WeasyPrint data:-URI-only SSRF-Policy, Briefpapier-@page-Frame mit running header/footer) +- Contract-Beitrag contacts: `document_placeholders(entity_type)`, `document_data(db, tenant_id, entity_id, entity_type)` (#359-Muster wie importexport_entities) +- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path-Konvergenz, RLS fail-closed nach 0084-Muster) +- Frontend: `api/documents.ts` + `DocumentSettings`-Page (Settings→Dokumente, eigener Menüpunkt via settings_pages) + `BlockEditor` (@dnd-kit: Palette/Canvas/Config-Panel/Live-Preview-iframe) + `LetterheadEditor` + `PrintTemplateEditor` + `DocumentGenerationDialog` (global für Module, integriert in ContactDetailPage) +- i18n de/en vollständig + +**Verifiziert:** +- ✅ tests/test_documents_generator.py: 32/32 (CRUD, Tenant-Isolation, RBAC 403, Block-Validierung 422, Preview, Render-PDF `%PDF`, Assets, Contract-Unit) +- ✅ Regression: test_report_generator.py + test_plugin_route_order.py 9/9 +- ✅ tsc --noEmit Exit 0; Production-Build OK (2.79s) +- ✅ Alembic-Fresh-DB: 0001→0143 komplett, letterheads/print_templates/document_assets mit RLS+FORCE+crm_api-Policy bewiesen (Scratch-DB wieder gedroppt) +- ✅ ruff check clean; check_migration_hashes 93/93 OK +- ⚠️ Bekannt: Router-Reihenfolge im Manifest — documents-Router muss VOR routes stehen (/{report_id}-Catch-all) + +**Offen (Folgepakete):** +- L4: KI-Steuerung („Erstelle Rechnungsvorlage") via agent_loop +- L5: E-Rechnung XRechnung/ZUGFeRD (benötigt Verkaufs-Modul) +- Weitere Module können Blöcke/Platzhalter beisteuern (Contract-Muster dokumentiert in plugin-development-guide.md) + ## W3b — Settings Contribution-Wahrheit (2026-08-28) ✅ **Verify-first (Live-Messung):** 7 Plugins liefern `settings_pages` via Manifest (mail, ai_assistant, ai_proactive, automation, permissions ×3, system_notif) — die hardcoded Items in `Settings.tsx` für mail/ai/notifications waren identische Duplikate. diff --git a/alembic/versions/0143_documents_generator.py b/alembic/versions/0143_documents_generator.py new file mode 100644 index 0000000..1ed70d2 --- /dev/null +++ b/alembic/versions/0143_documents_generator.py @@ -0,0 +1,115 @@ +"""Documents Generator tables (Phase L1): letterheads, print_templates, +document_assets. + +Revision ID: 0143 +Revises: 0142 +Create Date: 2026-08-29 + +Dual-path convergence (Gate B): on plugin-first installs the report_generator +plugin migration 0003 has already created these tables — skip instead of +failing. Both paths converge to the identical schema (see +app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql). +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from alembic import op + +revision = "0143" +down_revision = "0142" +branch_labels = None +depends_on = None + + +def _table_exists(conn, table_name: str) -> bool: + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + +def _rls(table: str) -> None: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") + op.execute( + f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE " + f"FOR ALL TO crm_api " + f"USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) " + f"WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)" + ) + + +def upgrade() -> None: + conn = op.get_bind() + if _table_exists(conn, "letterheads"): + return + + op.create_table( + "letterheads", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("config", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("created_by", PGUUID(as_uuid=True), nullable=False), + ) + op.create_index("ix_letterheads_tenant", "letterheads", ["tenant_id"]) + op.create_index("ix_letterheads_name", "letterheads", ["name"]) + + op.create_table( + "print_templates", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="SET NULL"), nullable=True), + sa.Column("entity_type", sa.String(100), nullable=False, server_default="contact"), + sa.Column("blocks", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column("output_format", sa.String(20), nullable=False, server_default="pdf"), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("created_by", PGUUID(as_uuid=True), nullable=False), + ) + op.create_index("ix_print_templates_tenant", "print_templates", ["tenant_id"]) + op.create_index("ix_print_templates_name", "print_templates", ["name"]) + + op.create_table( + "document_assets", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True), + sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="CASCADE"), nullable=True), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("mime_type", sa.String(100), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("storage_path", sa.String(1024), nullable=False), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("created_by", PGUUID(as_uuid=True), nullable=False), + ) + op.create_index("ix_document_assets_tenant", "document_assets", ["tenant_id"]) + op.create_index("ix_document_assets_letterhead", "document_assets", ["letterhead_id"]) + + for table in ("letterheads", "print_templates", "document_assets"): + _rls(table) + + +def downgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "letterheads"): + return + for table in ("document_assets", "print_templates", "letterheads"): + op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") + op.drop_table(table) diff --git a/app/plugins/builtins/contacts/contracts.py b/app/plugins/builtins/contacts/contracts.py index 02bcc83..0077565 100644 --- a/app/plugins/builtins/contacts/contracts.py +++ b/app/plugins/builtins/contacts/contracts.py @@ -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 diff --git a/app/plugins/builtins/report_generator/document_blocks.py b/app/plugins/builtins/report_generator/document_blocks.py new file mode 100644 index 0000000..2a7fc0e --- /dev/null +++ b/app/plugins/builtins/report_generator/document_blocks.py @@ -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 []) diff --git a/app/plugins/builtins/report_generator/document_renderer.py b/app/plugins/builtins/report_generator/document_renderer.py new file mode 100644 index 0000000..ab8113d --- /dev/null +++ b/app/plugins/builtins/report_generator/document_renderer.py @@ -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'

{rendered}

' + + if btype == "image": + url = _img_url(config, assets_map) + if not url: + return '
' + 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'
{alt}
' + + 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'
') + if shape == "rect": + return (f'
') + if shape == "circle": + size = height if height > 4 else 40 + return (f'
') + return "" + + if btype == "divider": + color = _html.escape(str(config.get("color", "#d1d5db"))) + thickness = int(config.get("thickness") or 1) + return f'
' + + if btype == "spacer": + height = int(config.get("height") or 24) + return f'
' + + 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 = "" + "".join(f"{_html.escape(str(c))}" for c in columns) + "" + body_rows = [] + for row in rows: + if not isinstance(row, (list, tuple)): + row = [row] + cells = "".join(f"{_render_text(str(c), data) if isinstance(c, str) else _html.escape(str(c))}" for c in row) + body_rows.append(f"{cells}") + body = "" + "".join(body_rows) + "" if body_rows else "" + width_style = f' style="width: {_html.escape(str(config["width"]))}"' if config.get("width") else "" + return f'{head}{body}
' + + if btype == "placeholder": + key = str(config.get("key", "")) + label = config.get("label") or key + value = data.get(key, "") + return (f'
' + f'{_html.escape(str(label))}: ' + f'{_html.escape(str(value if value is not None else ""))}
') + + if btype == "pagebreak": + return '
' + + # 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"{_html.escape(str(f))}{_html.escape(str(data.get(f, '')))}" + for f in fields + ) + return f'{rows}
' + 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'
{text}
' + ) + + 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""" + + + + + + +{f'
{header_html}
' if header_html else ''} +{f'' if footer_html else ''} +{watermark_html} +
{content}
+ +""" + + +# ─── 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 diff --git a/app/plugins/builtins/report_generator/documents.py b/app/plugins/builtins/report_generator/documents.py new file mode 100644 index 0000000..33d609a --- /dev/null +++ b/app/plugins/builtins/report_generator/documents.py @@ -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}"'}, + ) diff --git a/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql b/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql new file mode 100644 index 0000000..32cc39d --- /dev/null +++ b/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql @@ -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$; diff --git a/app/plugins/builtins/report_generator/models.py b/app/plugins/builtins/report_generator/models.py index 9bd9540..dca46ea 100644 --- a/app/plugins/builtins/report_generator/models.py +++ b/app/plugins/builtins/report_generator/models.py @@ -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) diff --git a/app/plugins/builtins/report_generator/plugin.py b/app/plugins/builtins/report_generator/plugin.py index d681faa..cecb87a 100644 --- a/app/plugins/builtins/report_generator/plugin.py +++ b/app/plugins/builtins/report_generator/plugin.py @@ -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 diff --git a/app/plugins/builtins/report_generator/schemas.py b/app/plugins/builtins/report_generator/schemas.py index 99deaef..8fe12b2 100644 --- a/app/plugins/builtins/report_generator/schemas.py +++ b/app/plugins/builtins/report_generator/schemas.py @@ -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 diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 82e5c6a..ae27ca3 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -511,6 +511,15 @@ Admin-only. Rebuild regenerates the embedding + TSV; purge sets embedding/TSV to | POST | `/api/v1/reports/generate` | Generate a report. | | GET | `/api/v1/reports/{report_id}` | Get report status. | | GET | `/api/v1/reports/{report_id}/download` | Download generated report. | +| GET/POST | `/api/v1/reports/letterheads` | Briefpapier CRUD (Phase L1). | +| GET/PUT/DELETE | `/api/v1/reports/letterheads/{letterhead_id}` | Einzelnes Briefpapier. | +| GET/POST | `/api/v1/reports/letterheads/{letterhead_id}/assets` | Logo/Bild-Upload für Briefpapier (data:-URI). | +| GET/POST | `/api/v1/reports/print-templates` | Druckvorlagen CRUD (Block-Komposition, Phase L1). | +| GET/PUT/DELETE | `/api/v1/reports/print-templates/{template_id}` | Einzelne Druckvorlage. | +| POST | `/api/v1/reports/print-templates/{template_id}/render` | Vorlage mit Entitätsdaten als PDF rendern (Phase L3). | +| GET | `/api/v1/reports/document-blocks` | Block-Registry (Built-in + Modul-Beiträge, Phase L1). | +| GET | `/api/v1/reports/document-placeholders` | Platzhalter-Registry pro Entity-Type (Modul-Beiträge). | +| POST | `/api/v1/reports/documents/preview` | Blöcke → HTML Live-Vorschau (Phase L2). | ### entity-links (Entity Linking) diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index 5dbf250..b878920 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -2652,3 +2652,41 @@ LeoCRM ships pre-built agents in the automation plugin. Plugins can register add --- *This document is authoritative for all plugin development at LeoCRM.* + +## Dokumente-Generator-Beitrag (Phase L) + +Module koennen dem Dokumente-Generator Bloecke, Platzhalter und Daten beisteuern — Contract-Muster wie Import/Export (`#359`-Philosophie). Alle drei Hooks sind optional: + +```python +# contracts.py des Plugins +class MyContract: + @staticmethod + def document_entity_types() -> list[str]: + return ["myentity"] + + @staticmethod + def document_placeholders(entity_type: str) -> list[dict]: + """[{key, label, example}] — speist Editor-Palette + Preview-Defaults.""" + if entity_type != "myentity": + return [] + return [{"key": "title", "label": "Titel", "example": "Beispiel AG"}] + + @staticmethod + async def document_data(db, tenant_id, entity_id, entity_type: str) -> dict: + """Eine Entitaet als Template-Daten laden ({} wenn nicht gefunden).""" + obj = await db.get(MyModel, entity_id) + if obj is None or obj.tenant_id != tenant_id: + return {} + return {"title": obj.title} + + @staticmethod + def document_blocks() -> list[dict]: + """Zusaetzliche Palette-Bloecke (generisch als Key-Value-Tabelle gerendert).""" + return [{"type": "myentity_summary", "label": "Entitaets-Uebersicht", "category": "modul", "fields": ["title", "status"]}] +``` + +**Regeln:** +- Rendering uebernimmt report_generator (document_renderer.py) — Module liefern NIE Markup (XSS/SSRF-Sandbox bleibt intakt) +- `document_placeholders`-Beispiele dienen als Preview-Fallbacks (StrictUndefined-vermeidend) +- Built-in-Bloecke duerfen nicht ueberschrieben werden (Contributions mit existierendem Typ werden ignoriert) +- Bilder laufen ausschliesslich als DocumentAsset/data:-URI (WeasyPrint-URL-Fetcher blockt extern) diff --git a/frontend/src/api/documents.ts b/frontend/src/api/documents.ts new file mode 100644 index 0000000..7306f28 --- /dev/null +++ b/frontend/src/api/documents.ts @@ -0,0 +1,296 @@ +/** + * Documents Generator API client (Phase L1-L3). + * + * Letterheads (Briefpapier), print templates (block compositions), + * block registry and placeholders — all under /reports/... (documents + * router of the report_generator plugin). + */ + +import { apiClient, apiDelete, apiGet, apiPost, apiPut } from './client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface DocBlock { + id: string; + type: string; + config: Record; +} + +export interface LetterheadPageConfig { + size?: string; + orientation?: string; + margins?: { top?: number; right?: number; bottom?: number; left?: number }; +} + +export interface LetterheadConfig { + page?: LetterheadPageConfig; + header?: { enabled: boolean; blocks: DocBlock[] }; + footer?: { enabled: boolean; blocks: DocBlock[] }; + watermark?: { enabled: boolean; text?: string }; +} + +export interface Letterhead { + id: string; + name: string; + description: string; + config: LetterheadConfig; + is_default: boolean; + created_by: string; + created_at?: string | null; + updated_at?: string | null; +} + +export interface PrintTemplate { + id: string; + name: string; + description: string; + letterhead_id: string | null; + entity_type: string; + blocks: DocBlock[]; + output_format: string; + created_by: string; + created_at?: string | null; + updated_at?: string | null; +} + +export interface DocumentBlockType { + type: string; + label: string; + category: string; + description: string; + fields: Record; + builtin: boolean; + contributed_by?: string | null; +} + +export interface DocumentPlaceholder { + key: string; + label: string; + example: string; +} + +export interface DocumentAsset { + id: string; + letterhead_id: string | null; + filename: string; + mime_type: string; + size_bytes: number; + data_url: string | null; + created_at?: string | null; +} + +export interface LetterheadInput { + name: string; + description?: string; + config?: LetterheadConfig; + is_default?: boolean; +} + +export interface PrintTemplateInput { + name: string; + description?: string; + letterhead_id?: string | null; + entity_type?: string; + blocks?: DocBlock[]; + output_format?: string; +} + +// ─── Query Keys ───────────────────────────────────────────────────────────── + +export const DOC_QUERY_KEYS = { + letterheads: ['documents', 'letterheads'] as const, + letterhead: (id: string) => ['documents', 'letterheads', id] as const, + templates: ['documents', 'print-templates'] as const, + template: (id: string) => ['documents', 'print-templates', id] as const, + blocks: ['documents', 'block-types'] as const, + placeholders: (entityType?: string) => ['documents', 'placeholders', entityType ?? 'all'] as const, + assets: (letterheadId: string) => ['documents', 'assets', letterheadId] as const, +}; + +// ─── Fetch helpers ───────────────────────────────────────────────────────── + +async function fetchLetterheads(): Promise { + const res = await apiGet<{ items: Letterhead[]; total: number }>('/reports/letterheads'); + return res.items ?? []; +} + +async function fetchTemplates(): Promise { + const res = await apiGet<{ items: PrintTemplate[]; total: number }>('/reports/print-templates'); + return res.items ?? []; +} + +async function fetchBlockTypes(): Promise { + return apiGet('/reports/document-blocks'); +} + +async function fetchPlaceholders(entityType?: string): Promise> { + const url = entityType + ? `/reports/document-placeholders?entity_type=${encodeURIComponent(entityType)}` + : '/reports/document-placeholders'; + return apiGet>(url); +} + +async function fetchAssets(letterheadId: string): Promise { + return apiGet(`/reports/letterheads/${letterheadId}/assets`); +} + +// ─── Hooks: Letterheads ───────────────────────────────────────────────────── + +export function useLetterheads() { + return useQuery({ + queryKey: DOC_QUERY_KEYS.letterheads, + queryFn: fetchLetterheads, + }); +} + +export function useCreateLetterhead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: LetterheadInput) => + apiPost('/reports/letterheads', input), + onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterheads }), + }); +} + +export function useUpdateLetterhead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: Partial }) => + apiPut(`/reports/letterheads/${id}`, input), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterheads }); + qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterhead(vars.id) }); + }, + }); +} + +export function useDeleteLetterhead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/reports/letterheads/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterheads }), + }); +} + +// ─── Hooks: Print Templates ───────────────────────────────────────────────── + +export function usePrintTemplates() { + return useQuery({ + queryKey: DOC_QUERY_KEYS.templates, + queryFn: fetchTemplates, + }); +} + +export function useCreatePrintTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: PrintTemplateInput) => + apiPost('/reports/print-templates', input), + onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.templates }), + }); +} + +export function useUpdatePrintTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: Partial }) => + apiPut(`/reports/print-templates/${id}`, input), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.templates }); + qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.template(vars.id) }); + }, + }); +} + +export function useDeletePrintTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/reports/print-templates/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.templates }), + }); +} + +// ─── Hooks: Registry ──────────────────────────────────────────────────────── + +export function useDocumentBlockTypes() { + return useQuery({ + queryKey: DOC_QUERY_KEYS.blocks, + queryFn: fetchBlockTypes, + staleTime: 5 * 60 * 1000, + }); +} + +export function useDocumentPlaceholders(entityType?: string, enabled = true) { + return useQuery>({ + queryKey: DOC_QUERY_KEYS.placeholders(entityType), + queryFn: () => fetchPlaceholders(entityType), + enabled, + staleTime: 5 * 60 * 1000, + }); +} + +export function useLetterheadAssets(letterheadId: string | null | undefined) { + return useQuery({ + queryKey: DOC_QUERY_KEYS.assets(letterheadId ?? 'none'), + queryFn: () => fetchAssets(letterheadId as string), + enabled: !!letterheadId, + }); +} + +export function useUploadLetterheadAsset() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ letterheadId, file }: { letterheadId: string; file: File }) => { + const form = new FormData(); + form.append('file', file); + return apiClient + .post(`/reports/letterheads/${letterheadId}/assets`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + .then((r) => r.data as DocumentAsset); + }, + onSuccess: (_data, vars) => + qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.assets(vars.letterheadId) }), + }); +} + +// ─── Preview & Render ────────────────────────────────────────────────────── + +export interface PreviewInput { + blocks: DocBlock[]; + letterhead_config: LetterheadConfig | null; + entity_type?: string | null; + data?: Record | null; +} + +export function usePreviewDocument() { + return useMutation({ + mutationFn: (input: PreviewInput) => + apiPost<{ html: string }>('/reports/documents/preview', input), + }); +} + +export interface RenderInput { + templateId: string; + entityType: string; + entityId: string; + outputFormat?: string; +} + +export async function renderPrintTemplate(input: RenderInput): Promise { + const response = await apiClient.post( + `/reports/print-templates/${input.templateId}/render`, + { + entity_type: input.entityType, + entity_id: input.entityId, + output_format: input.outputFormat ?? 'pdf', + }, + { responseType: 'blob' }, + ); + return response.data as Blob; +} + +export function useRenderPrintTemplate() { + return useMutation({ mutationFn: renderPrintTemplate }); +} diff --git a/frontend/src/components/documents/BlockEditor.tsx b/frontend/src/components/documents/BlockEditor.tsx new file mode 100644 index 0000000..52fdc3f --- /dev/null +++ b/frontend/src/components/documents/BlockEditor.tsx @@ -0,0 +1,530 @@ +/** + * BlockEditor — drag&drop block editor (Phase L2). + * + * Shared canvas for letterhead (header/footer) and print template blocks: + * - Palette: builtin + module-contributed block types (from /document-blocks) + * - Sortable canvas (@dnd-kit): reorder, remove, select + * - Config panel: per-type fields (text content, shape params, table, image) + * - Live preview: debounced POST /documents/preview rendered into an iframe + */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical, Trash2, Plus, Eye } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { useDocumentBlockTypes, useDocumentPlaceholders, usePreviewDocument, type DocBlock } from '@/api/documents'; + +let blockIdCounter = 0; +function nextBlockId(): string { + blockIdCounter += 1; + return `blk_${Date.now().toString(36)}_${blockIdCounter}`; +} + +function defaultConfigFor(type: string): Record { + switch (type) { + case 'text': + return { content: '' }; + case 'image': + return { asset_id: null, width: 200, align: 'left', alt: '' }; + case 'shape': + return { shape: 'line', width: '100%', height: 2, color: '#111827' }; + case 'table': + return { columns: [], rows: [], striped: true }; + case 'spacer': + return { height: 24 }; + case 'divider': + return { color: '#d1d5db', thickness: 1 }; + case 'placeholder': + return { key: '', label: '' }; + default: + return {}; + } +} + +interface SortableBlockProps { + block: DocBlock; + label: string; + selected: boolean; + onSelect: () => void; + onRemove: () => void; +} + +function SortableBlock({ block, label, selected, onSelect, onRemove }: SortableBlockProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: block.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const summary = useMemo(() => { + const c = block.config ?? {}; + switch (block.type) { + case 'text': + return typeof c.content === 'string' ? (c.content as string).slice(0, 60) : ''; + case 'image': + return c.asset_id ? `Asset: ${String(c.asset_id).slice(0, 8)}…` : 'Bild'; + case 'shape': + return `Grafik: ${String(c.shape ?? '')}`; + case 'table': + return `Tabelle (${Array.isArray(c.rows) ? (c.rows as unknown[]).length : 0} Zeilen)`; + case 'spacer': + return `Abstand: ${String(c.height ?? 24)}px`; + case 'divider': + return 'Trennlinie'; + case 'placeholder': + return c.key ? `{{${String(c.key)}}}` : 'Platzhalter'; + case 'pagebreak': + return 'Seitenwechsel'; + default: + return label; + } + }, [block, label]); + + return ( +
+ + + +
+ ); +} + +// ─── Config panels per block type ────────────────────────────────────────── + +interface ConfigPanelProps { + block: DocBlock; + placeholders: { key: string; label: string; example: string }[]; + onChange: (config: Record) => void; +} + +function ConfigPanel({ block, placeholders, onChange }: ConfigPanelProps) { + const { t } = useTranslation(); + const c = block.config ?? {}; + + const set = (key: string, value: unknown) => onChange({ ...c, [key]: value }); + + if (block.type === 'text') { + return ( +
+
+ +