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
+25 -2
View File
@@ -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) 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 `<Routes>` 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) 5. **Sidebar /contacts statische Route entfernen** — ✅ erledigt (2026-08-29): Kritikpunkt 21 zuerst erfüllt (Renderer bewiesen, DANN entfernt). PluginRouteRenderer komplett neu: verschachtelte `<Routes>` 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 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'). **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) ## 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 **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) ✅ ## 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. **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.
@@ -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)
+108
View File
@@ -308,6 +308,47 @@ class ContactsContract:
) )
return _serialize_contact(contact) 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 @classmethod
def get_function(cls, name: str): def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent.""" """Return a callable exposed by this contract, or None if absent."""
@@ -318,3 +359,70 @@ class ContactsContract:
_contract = ContactsContract() _contract = ContactsContract()
get_contract_registry().register("contacts", _contract) 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
@@ -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 [])
@@ -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'<p class="doc-block doc-text"{_style_attr(config.get("style"))}>{rendered}</p>'
if btype == "image":
url = _img_url(config, assets_map)
if not url:
return '<div class="doc-block doc-image-missing" data-missing="true"></div>'
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'<div class="doc-block doc-image" style="text-align: {_html.escape(str(align))}"><img src="{url}" alt="{alt}"{dims} /></div>'
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'<hr class="doc-block doc-shape" style="border: none; '
f'border-top: {height}px solid {color}; width: {_html.escape(str(width))}; margin: 8px 0;" />')
if shape == "rect":
return (f'<div class="doc-block doc-shape" style="width: {_html.escape(str(width))}; '
f'height: {height}px; background: {background}; border: 1px solid {color}; margin: 8px 0;"></div>')
if shape == "circle":
size = height if height > 4 else 40
return (f'<div class="doc-block doc-shape" style="width: {size}px; height: {size}px; '
f'background: {background}; border: 1px solid {color}; border-radius: {radius}%; margin: 8px 0;"></div>')
return ""
if btype == "divider":
color = _html.escape(str(config.get("color", "#d1d5db")))
thickness = int(config.get("thickness") or 1)
return f'<hr class="doc-block doc-divider" style="border: none; border-top: {thickness}px solid {color}; margin: 12px 0;" />'
if btype == "spacer":
height = int(config.get("height") or 24)
return f'<div class="doc-block doc-spacer" style="height: {height}px;"></div>'
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 = "<thead><tr>" + "".join(f"<th>{_html.escape(str(c))}</th>" for c in columns) + "</tr></thead>"
body_rows = []
for row in rows:
if not isinstance(row, (list, tuple)):
row = [row]
cells = "".join(f"<td>{_render_text(str(c), data) if isinstance(c, str) else _html.escape(str(c))}</td>" for c in row)
body_rows.append(f"<tr>{cells}</tr>")
body = "<tbody>" + "".join(body_rows) + "</tbody>" if body_rows else ""
width_style = f' style="width: {_html.escape(str(config["width"]))}"' if config.get("width") else ""
return f'<table class="doc-block doc-table{striped}"{width_style}>{head}{body}</table>'
if btype == "placeholder":
key = str(config.get("key", ""))
label = config.get("label") or key
value = data.get(key, "")
return (f'<div class="doc-block doc-placeholder"><span class="doc-placeholder-label">'
f'{_html.escape(str(label))}:</span> <span class="doc-placeholder-value">'
f'{_html.escape(str(value if value is not None else ""))}</span></div>')
if btype == "pagebreak":
return '<div class="doc-block doc-pagebreak" style="break-after: page;"></div>'
# 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"<tr><th>{_html.escape(str(f))}</th><td>{_html.escape(str(data.get(f, '')))}</td></tr>"
for f in fields
)
return f'<table class="doc-block doc-contribution"><tbody>{rows}</tbody></table>'
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'<div class="doc-watermark">{text}</div>'
)
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"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
@page {{
size: {page_css};
margin: {int(m_top) + extra_top}mm {int(m_right)}mm {int(m_bottom) + extra_bottom}mm {int(m_left)}mm;
}}
body {{ font-family: 'Helvetica', 'Arial', sans-serif; font-size: 11pt; color: #111827; line-height: 1.5; }}
.doc-text {{ margin: 0 0 10px 0; white-space: pre-wrap; }}
.doc-table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
.doc-table th, .doc-table td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
.doc-table-striped tbody tr:nth-child(even) {{ background: #f9fafb; }}
.doc-placeholder-label {{ font-weight: 600; color: #374151; }}
.doc-placeholder {{ margin: 4px 0; }}
.doc-watermark {{ position: fixed; top: 45%; left: 0; right: 0; text-align: center; font-size: 48pt; color: rgba(107, 114, 128, 0.25); transform: rotate(-30deg); }}
{header_css}{footer_css}
</style>
</head>
<body>
{f'<div id="doc-header">{header_html}</div>' if header_html else ''}
{f'<div id="doc-footer">{footer_html}</div>' if footer_html else ''}
{watermark_html}
<div id="doc-content">{content}</div>
</body>
</html>"""
# ─── 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
@@ -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}"'},
)
@@ -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$;
@@ -4,7 +4,8 @@ from __future__ import annotations
import uuid 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.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column 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) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) 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)
@@ -3,7 +3,13 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin 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): class ReportGeneratorPlugin(BasePlugin):
@@ -17,12 +23,31 @@ class ReportGeneratorPlugin(BasePlugin):
is_core=True, is_core=True,
dependencies=["permissions"], dependencies=["permissions"],
routes=[ 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( PluginRouteDef(
path="/api/v1/reports", path="/api/v1/reports",
module="app.plugins.builtins.report_generator.routes", module="app.plugins.builtins.report_generator.routes",
router_attr="router", 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"], events=["report.requested", "report.generated"],
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"], migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"], permissions=["reports:read", "reports:generate", "reports:manage_templates"],
@@ -38,8 +63,20 @@ class ReportGeneratorPlugin(BasePlugin):
contract_version="1.0.0") contract_version="1.0.0")
def get_entity_models(self) -> dict[str, type]: def get_entity_models(self) -> dict[str, type]:
from app.plugins.builtins.report_generator.models import ReportInstance, ReportTemplate from app.plugins.builtins.report_generator.models import (
return {"report_template": ReportTemplate, "report_instance": ReportInstance} DocumentAsset,
Letterhead,
PrintTemplate,
ReportInstance,
ReportTemplate,
)
return {
"report_template": ReportTemplate,
"report_instance": ReportInstance,
"letterhead": Letterhead,
"print_template": PrintTemplate,
"document_asset": DocumentAsset,
}
async def on_activate( async def on_activate(
self, db, service_container, event_bus self, db, service_container, event_bus
@@ -72,3 +72,93 @@ class ReportResponse(BaseModel):
created_by: str created_by: str
created_at: datetime | None = None created_at: datetime | None = None
updated_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
+9
View File
@@ -511,6 +511,15 @@ Admin-only. Rebuild regenerates the embedding + TSV; purge sets embedding/TSV to
| POST | `/api/v1/reports/generate` | Generate a report. | | POST | `/api/v1/reports/generate` | Generate a report. |
| GET | `/api/v1/reports/{report_id}` | Get report status. | | GET | `/api/v1/reports/{report_id}` | Get report status. |
| GET | `/api/v1/reports/{report_id}/download` | Download generated report. | | 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) ### entity-links (Entity Linking)
+38
View File
@@ -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.* *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)
+296
View File
@@ -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<string, unknown>;
}
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<string, string>;
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<Letterhead[]> {
const res = await apiGet<{ items: Letterhead[]; total: number }>('/reports/letterheads');
return res.items ?? [];
}
async function fetchTemplates(): Promise<PrintTemplate[]> {
const res = await apiGet<{ items: PrintTemplate[]; total: number }>('/reports/print-templates');
return res.items ?? [];
}
async function fetchBlockTypes(): Promise<DocumentBlockType[]> {
return apiGet<DocumentBlockType[]>('/reports/document-blocks');
}
async function fetchPlaceholders(entityType?: string): Promise<Record<string, DocumentPlaceholder[]>> {
const url = entityType
? `/reports/document-placeholders?entity_type=${encodeURIComponent(entityType)}`
: '/reports/document-placeholders';
return apiGet<Record<string, DocumentPlaceholder[]>>(url);
}
async function fetchAssets(letterheadId: string): Promise<DocumentAsset[]> {
return apiGet<DocumentAsset[]>(`/reports/letterheads/${letterheadId}/assets`);
}
// ─── Hooks: Letterheads ─────────────────────────────────────────────────────
export function useLetterheads() {
return useQuery<Letterhead[]>({
queryKey: DOC_QUERY_KEYS.letterheads,
queryFn: fetchLetterheads,
});
}
export function useCreateLetterhead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: LetterheadInput) =>
apiPost<Letterhead>('/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<LetterheadInput> }) =>
apiPut<Letterhead>(`/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<PrintTemplate[]>({
queryKey: DOC_QUERY_KEYS.templates,
queryFn: fetchTemplates,
});
}
export function useCreatePrintTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: PrintTemplateInput) =>
apiPost<PrintTemplate>('/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<PrintTemplateInput> }) =>
apiPut<PrintTemplate>(`/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<DocumentBlockType[]>({
queryKey: DOC_QUERY_KEYS.blocks,
queryFn: fetchBlockTypes,
staleTime: 5 * 60 * 1000,
});
}
export function useDocumentPlaceholders(entityType?: string, enabled = true) {
return useQuery<Record<string, DocumentPlaceholder[]>>({
queryKey: DOC_QUERY_KEYS.placeholders(entityType),
queryFn: () => fetchPlaceholders(entityType),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useLetterheadAssets(letterheadId: string | null | undefined) {
return useQuery<DocumentAsset[]>({
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<string, unknown> | 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<Blob> {
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 });
}
@@ -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<string, unknown> {
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 (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
selected ? 'border-primary-400 bg-primary-50' : 'border-secondary-200 bg-white hover:bg-secondary-50'
}`}
data-testid={`block-editor-item-${block.id}`}
>
<button
className="cursor-grab touch-none text-secondary-400 hover:text-secondary-600"
aria-label="Block verschieben"
{...attributes}
{...listeners}
>
<GripVertical className="w-4 h-4" aria-hidden="true" />
</button>
<button className="flex-1 text-left min-w-0" onClick={onSelect} aria-label={`${label} auswählen`}>
<span className="text-xs font-medium text-secondary-500 uppercase">{label}</span>
<span className="block text-sm text-secondary-900 truncate">{summary || label}</span>
</button>
<button
onClick={onRemove}
className="text-secondary-400 hover:text-danger-600 p-1"
aria-label="Block entfernen"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</button>
</div>
);
}
// ─── Config panels per block type ──────────────────────────────────────────
interface ConfigPanelProps {
block: DocBlock;
placeholders: { key: string; label: string; example: string }[];
onChange: (config: Record<string, unknown>) => 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 (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-text-content">
{t('documents.editor.content', 'Inhalt')} {t('documents.editor.placeholdersHint', '{{platzhalter}} möglich')}
</label>
<textarea
id="blk-text-content"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm min-h-[100px] focus:outline-none focus:ring-2 focus:ring-primary-500"
value={typeof c.content === 'string' ? c.content : ''}
onChange={(e) => set('content', e.target.value)}
data-testid="block-config-text-content"
/>
</div>
<div className="flex gap-2 items-center">
<label className="text-xs font-medium text-secondary-500 flex items-center gap-1">
<input type="checkbox" checked={!!c.bold} onChange={(e) => set('bold', e.target.checked)} />
{t('documents.editor.bold', 'Fett')}
</label>
<label className="text-xs font-medium text-secondary-500 flex items-center gap-1">
<input type="checkbox" checked={!!c.italic} onChange={(e) => set('italic', e.target.checked)} />
{t('documents.editor.italic', 'Kursiv')}
</label>
</div>
{placeholders.length > 0 && (
<div className="flex flex-wrap gap-1">
{placeholders.map((p) => (
<button
key={p.key}
type="button"
className="text-xs px-2 py-0.5 rounded bg-secondary-100 hover:bg-primary-100 text-secondary-700"
title={p.label}
onClick={() => set('content', `${typeof c.content === 'string' ? c.content : ''}{{${p.key}}}`)}
>
{`{{${p.key}}}`}
</button>
))}
</div>
)}
</div>
);
}
if (block.type === 'shape') {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-shape">{t('documents.editor.shape', 'Form')}</label>
<select
id="blk-shape"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={String(c.shape ?? 'line')}
onChange={(e) => set('shape', e.target.value)}
>
<option value="line">{t('documents.editor.line', 'Linie')}</option>
<option value="rect">{t('documents.editor.rect', 'Rechteck')}</option>
<option value="circle">{t('documents.editor.circle', 'Kreis')}</option>
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-width">{t('documents.editor.width', 'Breite')}</label>
<Input id="blk-width" value={String(c.width ?? '100%')} onChange={(e) => set('width', e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-height">{t('documents.editor.height', 'Höhe (px)')}</label>
<Input
id="blk-height"
type="number"
value={String(c.height ?? 2)}
onChange={(e) => set('height', Number(e.target.value) || 1)}
/>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-color">{t('documents.editor.color', 'Farbe')}</label>
<Input id="blk-color" value={String(c.color ?? '#111827')} onChange={(e) => set('color', e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-bg">{t('documents.editor.background', 'Füllung')}</label>
<Input id="blk-bg" value={String(c.background ?? '#e5e7eb')} onChange={(e) => set('background', e.target.value)} />
</div>
</div>
</div>
);
}
if (block.type === 'image') {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-asset">{t('documents.editor.assetId', 'Asset-ID (Briefpapier-Upload)')}</label>
<Input id="blk-asset" value={String(c.asset_id ?? '')} onChange={(e) => set('asset_id', e.target.value)} placeholder="UUID" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-img-width">{t('documents.editor.width', 'Breite (px)')}</label>
<Input id="blk-img-width" type="number" value={String(c.width ?? 200)} onChange={(e) => set('width', Number(e.target.value) || undefined)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-img-alt">Alt</label>
<Input id="blk-img-alt" value={String(c.alt ?? '')} onChange={(e) => set('alt', e.target.value)} />
</div>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-align">{t('documents.editor.align', 'Ausrichtung')}</label>
<select id="blk-align" className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm" value={String(c.align ?? 'left')} onChange={(e) => set('align', e.target.value)}>
<option value="left">{t('documents.editor.left', 'Links')}</option>
<option value="center">{t('documents.editor.center', 'Zentriert')}</option>
<option value="right">{t('documents.editor.right', 'Rechts')}</option>
</select>
</div>
</div>
);
}
if (block.type === 'table') {
const columns = Array.isArray(c.columns) ? (c.columns as string[]).join(', ') : '';
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-cols">{t('documents.editor.columns', 'Spalten (Komma-getrennt)')}</label>
<Input id="blk-cols" value={columns} onChange={(e) => set('columns', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-rows">{t('documents.editor.rows', 'Zeilen (JSON)')}</label>
<textarea
id="blk-rows"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm font-mono min-h-[80px]"
value={JSON.stringify(c.rows ?? [], null, 0)}
onChange={(e) => {
try {
set('rows', JSON.parse(e.target.value));
} catch {
/* ignore invalid JSON while typing */
}
}}
/>
</div>
</div>
);
}
if (block.type === 'spacer') {
return (
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-spacer">{t('documents.editor.height', 'Höhe (px)')}</label>
<Input id="blk-spacer" type="number" value={String(c.height ?? 24)} onChange={(e) => set('height', Number(e.target.value) || 24)} />
</div>
);
}
if (block.type === 'divider') {
return (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-div-color">{t('documents.editor.color', 'Farbe')}</label>
<Input id="blk-div-color" value={String(c.color ?? '#d1d5db')} onChange={(e) => set('color', e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-div-thick">{t('documents.editor.thickness', 'Dicke (px)')}</label>
<Input id="blk-div-thick" type="number" value={String(c.thickness ?? 1)} onChange={(e) => set('thickness', Number(e.target.value) || 1)} />
</div>
</div>
);
}
if (block.type === 'placeholder') {
return (
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-ph-key">{t('documents.editor.placeholderKey', 'Datenfeld')}</label>
<select
id="blk-ph-key"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={String(c.key ?? '')}
onChange={(e) => {
const ph = placeholders.find((p) => p.key === e.target.value);
set('key', e.target.value);
if (ph && !c.label) set('label', ph.label);
}}
>
<option value=""></option>
{placeholders.map((p) => (
<option key={p.key} value={p.key}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-ph-label">{t('documents.editor.label', 'Anzeige-Label')}</label>
<Input id="blk-ph-label" value={String(c.label ?? '')} onChange={(e) => set('label', e.target.value)} />
</div>
</div>
);
}
if (block.type === 'pagebreak') {
return <p className="text-xs text-secondary-500">{t('documents.editor.pagebreakHint', 'Erzwingt einen Seitenwechsel im PDF.')}</p>;
}
return <p className="text-xs text-secondary-500">{t('documents.editor.noConfig', 'Keine Konfiguration für diesen Block-Typ.')}</p>;
}
// ─── Main BlockEditor ─────────────────────────────────────────────────────
export interface BlockEditorProps {
blocks: DocBlock[];
onChange: (blocks: DocBlock[]) => void;
/** entity_type for placeholder palette (e.g. 'contact') */
entityType?: string | null;
/** full letterhead config for preview (null = no frame) */
letterheadConfig?: unknown;
/** preview data override (defaults to placeholder examples) */
previewData?: Record<string, unknown> | null;
}
export function BlockEditor({ blocks, onChange, entityType, letterheadConfig, previewData }: BlockEditorProps) {
const { t } = useTranslation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [previewHtml, setPreviewHtml] = useState<string>('');
const { data: blockTypes = [] } = useDocumentBlockTypes();
const { data: placeholderMap } = useDocumentPlaceholders(entityType ?? undefined, !!entityType);
const preview = usePreviewDocument();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const placeholders = useMemo(() => {
if (!entityType || !placeholderMap) return [];
return placeholderMap[entityType] ?? [];
}, [entityType, placeholderMap]);
const selected = useMemo(() => blocks.find((b) => b.id === selectedId) ?? null, [blocks, selectedId]);
const selectedMeta = useMemo(() => blockTypes.find((b) => b.type === selected?.type) ?? null, [blockTypes, selected]);
const addBlock = (type: string) => {
const block: DocBlock = { id: nextBlockId(), type, config: defaultConfigFor(type) };
onChange([...blocks, block]);
setSelectedId(block.id);
};
const removeBlock = (id: string) => {
onChange(blocks.filter((b) => b.id !== id));
if (selectedId === id) setSelectedId(null);
};
const updateConfig = (id: string, config: Record<string, unknown>) => {
onChange(blocks.map((b) => (b.id === id ? { ...b, config } : b)));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = blocks.findIndex((b) => b.id === active.id);
const newIndex = blocks.findIndex((b) => b.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onChange(arrayMove(blocks, oldIndex, newIndex));
};
// debounced live preview
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(async () => {
try {
const res = await preview.mutateAsync({
blocks,
letterhead_config: (letterheadConfig as never) ?? null,
entity_type: entityType ?? null,
data: previewData ?? null,
});
setPreviewHtml(res.html);
} catch {
/* preview errors are non-fatal (e.g. invalid block while typing) */
}
}, 600);
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(blocks), entityType, JSON.stringify(letterheadConfig)]);
return (
<div className="grid grid-cols-12 gap-4" data-testid="block-editor">
{/* Palette */}
<div className="col-span-3 space-y-2" data-testid="block-editor-palette">
<h3 className="text-xs font-semibold text-secondary-500 uppercase">{t('documents.editor.palette', 'Blöcke')}</h3>
{['basis', 'grafik', 'layout', 'daten', 'modul'].map((cat) => {
const items = blockTypes.filter((b) => b.category === cat);
if (items.length === 0) return null;
return (
<div key={cat} className="space-y-1">
<p className="text-[10px] text-secondary-400 uppercase">{cat}</p>
{items.map((bt) => (
<button
key={bt.type}
onClick={() => addBlock(bt.type)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md border border-secondary-200 text-sm hover:bg-primary-50 hover:border-primary-300 transition-colors"
title={bt.description}
data-testid={`block-editor-add-${bt.type}`}
>
<Plus className="w-3.5 h-3.5 text-secondary-400" aria-hidden="true" />
{bt.label}
</button>
))}
</div>
);
})}
</div>
{/* Canvas */}
<div className="col-span-4 space-y-2" data-testid="block-editor-canvas">
<h3 className="text-xs font-semibold text-secondary-500 uppercase">{t('documents.editor.canvas', 'Reihenfolge')}</h3>
{blocks.length === 0 ? (
<p className="text-xs text-secondary-400 py-8 text-center border border-dashed border-secondary-200 rounded-lg">
{t('documents.editor.empty', 'Blöcke aus der Palette hinzufügen')}
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{blocks.map((b) => {
const meta = blockTypes.find((t2) => t2.type === b.type);
return (
<SortableBlock
key={b.id}
block={b}
label={meta?.label ?? b.type}
selected={selectedId === b.id}
onSelect={() => setSelectedId(b.id)}
onRemove={() => removeBlock(b.id)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Config */}
<div className="col-span-5 space-y-2" data-testid="block-editor-config">
<h3 className="text-xs font-semibold text-secondary-500 uppercase flex items-center gap-1">
<Eye className="w-3 h-3" aria-hidden="true" />
{t('documents.editor.configPreview', 'Konfiguration & Vorschau')}
</h3>
{selected ? (
<div className="border border-secondary-200 rounded-lg p-3 bg-white space-y-3">
<p className="text-sm font-medium text-secondary-900">{selectedMeta?.label ?? selected.type}</p>
<ConfigPanel
block={selected}
placeholders={placeholders}
onChange={(config) => updateConfig(selected.id, config)}
/>
</div>
) : (
<p className="text-xs text-secondary-400 border border-dashed border-secondary-200 rounded-lg p-3">
{t('documents.editor.selectBlock', 'Block in der Reihenfolge-Liste auswählen, um ihn zu konfigurieren.')}
</p>
)}
<div className="border border-secondary-200 rounded-lg overflow-hidden bg-white">
<iframe
title={t('documents.editor.preview', 'Live-Vorschau')}
srcDoc={previewHtml}
className="w-full h-[420px] bg-white"
data-testid="block-editor-preview"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
/**
* DocumentGenerationDialog global document generation dialog (Phase L2).
*
* Any module can open this dialog with an entityType + entityId to render
* a print template to PDF. The dialog lists the tenant's templates filtered
* by entity_type, triggers /print-templates/{id}/render and downloads the
* resulting blob.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileDown, Loader2 } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import {
usePrintTemplates,
useRenderPrintTemplate,
type PrintTemplate,
} from '@/api/documents';
export interface DocumentGenerationDialogProps {
open: boolean;
onClose: () => void;
/** entity type for template filtering + render (e.g. 'contact', 'company') */
entityType: string;
/** entity to render (data source via module contribution) */
entityId: string;
/** optional display name for the header */
entityLabel?: string;
}
export function DocumentGenerationDialog({
open,
onClose,
entityType,
entityId,
entityLabel,
}: DocumentGenerationDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data: templates = [], isLoading } = usePrintTemplates();
const render = useRenderPrintTemplate();
// templates matching the entity type (exact match wins; 'contact' as
// generic fallback so contact/person/company templates are all offered)
const matching = useMemo(() => {
const exact = templates.filter((tpl) => tpl.entity_type === entityType);
if (exact.length > 0) return exact;
if (entityType === 'company' || entityType === 'person') {
return templates.filter((tpl) => tpl.entity_type === 'contact');
}
return [];
}, [templates, entityType]);
const selected = matching.find((tpl) => tpl.id === selectedId) ?? null;
const handleGenerate = async (template: PrintTemplate) => {
try {
const blob = await render.mutateAsync({
templateId: template.id,
entityType,
entityId,
});
// trigger browser download
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${template.name.replace(/\s+/g, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success(t('documents.dialog.generated', 'Dokument wurde erstellt'));
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : '';
toast.error(t('documents.dialog.generateFailed', 'Dokument konnte nicht erstellt werden'));
if (message) console.warn('[DocumentGenerationDialog]', message);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={t('documents.dialog.title', 'Dokument erstellen')}
size="md"
>
<div className="space-y-4" data-testid="document-generation-dialog">
{entityLabel && (
<p className="text-sm text-secondary-500">
{t('documents.dialog.for', 'Für')}: <span className="font-medium text-secondary-900">{entityLabel}</span>
</p>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8" role="status">
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
</div>
) : matching.length === 0 ? (
<p className="text-sm text-secondary-500 py-6 text-center">
{t('documents.dialog.noTemplates', 'Keine Druckvorlagen für diesen Datentyp. Vorlagen unter Einstellungen → Dokumente anlegen.')}
</p>
) : (
<ul className="divide-y divide-secondary-100" data-testid="document-dialog-template-list">
{matching.map((tpl) => (
<li key={tpl.id} className="flex items-center justify-between py-3 gap-3">
<div className="min-w-0">
<p className="font-medium text-secondary-900 truncate">{tpl.name}</p>
<p className="text-xs text-secondary-500 truncate">{tpl.description || '—'}</p>
</div>
<Button
size="sm"
icon={<FileDown className="w-4 h-4" aria-hidden="true" />}
onClick={() => handleGenerate(tpl)}
isLoading={render.isPending && render.variables?.templateId === tpl.id}
data-testid={`document-dialog-generate-${tpl.id}`}
>
{t('documents.dialog.generate', 'Erstellen')}
</Button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,291 @@
/**
* LetterheadEditor Briefpapier-Editor (Phase L2).
*
* Page setup (size/orientation/margins) + header/footer block composition
* (drag&drop via BlockEditor) + logo upload (DocumentAsset).
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Upload } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useToast } from '@/components/ui/Toast';
import { Skeleton } from '@/components/ui/Skeleton';
import {
useCreateLetterhead,
useUpdateLetterhead,
useLetterheadAssets,
useUploadLetterheadAsset,
type DocBlock,
type Letterhead,
type LetterheadConfig,
} from '@/api/documents';
import { BlockEditor } from './BlockEditor';
interface LetterheadEditorProps {
letterhead: Letterhead | null;
onClose: () => void;
}
const DEFAULT_CONFIG: LetterheadConfig = {
page: { size: 'A4', orientation: 'portrait', margins: { top: 25, right: 20, bottom: 25, left: 20 } },
header: { enabled: true, blocks: [] },
footer: { enabled: false, blocks: [] },
watermark: { enabled: false, text: '' },
};
export function LetterheadEditor({ letterhead, onClose }: LetterheadEditorProps) {
const { t } = useTranslation();
const toast = useToast();
const [name, setName] = useState(letterhead?.name ?? '');
const [description, setDescription] = useState(letterhead?.description ?? '');
const [isDefault, setIsDefault] = useState(letterhead?.is_default ?? false);
const [config, setConfig] = useState<LetterheadConfig>(letterhead?.config ?? DEFAULT_CONFIG);
const [section, setSection] = useState<'header' | 'footer'>('header');
const createMutation = useCreateLetterhead();
const updateMutation = useUpdateLetterhead();
const uploadAsset = useUploadLetterheadAsset();
const { data: assets = [], isLoading: assetsLoading } = useLetterheadAssets(letterhead?.id ?? null);
useEffect(() => {
if (letterhead) {
setName(letterhead.name);
setDescription(letterhead.description);
setIsDefault(letterhead.is_default);
setConfig({ ...DEFAULT_CONFIG, ...letterhead.config });
}
}, [letterhead]);
const setSectionBlocks = (blocks: DocBlock[]) => {
setConfig((c) => ({ ...c, [section]: { ...(c[section] ?? { enabled: true, blocks: [] }), blocks } }));
};
const handleSave = async () => {
if (!name.trim()) {
toast.error(t('documents.letterhead.nameRequired', 'Name ist erforderlich'));
return;
}
const input = { name: name.trim(), description, config, is_default: isDefault };
try {
if (letterhead) {
await updateMutation.mutateAsync({ id: letterhead.id, input });
toast.success(t('documents.letterhead.saved', 'Briefpapier gespeichert'));
} else {
const created = await createMutation.mutateAsync(input);
toast.success(t('documents.letterhead.created', 'Briefpapier angelegt'));
// upload pending logo files after create? (uploads happen via assets panel below)
void created;
}
onClose();
} catch {
toast.error(t('common.error', 'Speichern fehlgeschlagen'));
}
};
const page = config.page ?? { size: 'A4', orientation: 'portrait', margins: { top: 25, right: 20, bottom: 25, left: 20 } };
const margins = page.margins ?? { top: 25, right: 20, bottom: 25, left: 20 };
return (
<Modal
open
onClose={onClose}
title={letterhead ? t('documents.letterhead.edit', 'Briefpapier bearbeiten') : t('documents.letterhead.create', 'Neues Briefpapier')}
size="xl"
>
<div className="space-y-4" data-testid="letterhead-editor">
{/* Meta */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-name">{t('common.name', 'Name')}</label>
<Input id="lh-name" value={name} onChange={(e) => setName(e.target.value)} data-testid="letterhead-name-input" />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-desc">{t('common.description', 'Beschreibung')}</label>
<Input id="lh-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-secondary-700">
<input
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
data-testid="letterhead-default-checkbox"
/>
{t('documents.letterhead.isDefault', 'Als Standard-Briefpapier verwenden')}
</label>
{/* Page setup */}
<div className="grid grid-cols-4 gap-3 border border-secondary-200 rounded-lg p-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-size">{t('documents.page.size', 'Seitenformat')}</label>
<select
id="lh-size"
className="mt-1 w-full rounded-md border border-secondary-300 px-2 py-1.5 text-sm"
value={page.size ?? 'A4'}
onChange={(e) => setConfig((c) => ({ ...c, page: { ...(c.page ?? {}), size: e.target.value } }))}
>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="letter">Letter</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-orient">{t('documents.page.orientation', 'Ausrichtung')}</label>
<select
id="lh-orient"
className="mt-1 w-full rounded-md border border-secondary-300 px-2 py-1.5 text-sm"
value={page.orientation ?? 'portrait'}
onChange={(e) => setConfig((c) => ({ ...c, page: { ...(c.page ?? {}), orientation: e.target.value } }))}
>
<option value="portrait">{t('documents.page.portrait', 'Hochformat')}</option>
<option value="landscape">{t('documents.page.landscape', 'Querformat')}</option>
</select>
</div>
<div className="col-span-2">
<p className="text-xs font-medium text-secondary-500 mb-1">{t('documents.page.margins', 'Seitenränder (mm)')}</p>
<div className="grid grid-cols-4 gap-2">
{(['top', 'right', 'bottom', 'left'] as const).map((side) => (
<div key={side}>
<label className="text-[10px] text-secondary-400" htmlFor={`lh-margin-${side}`}>{side}</label>
<Input
id={`lh-margin-${side}`}
type="number"
value={margins[side] ?? 20}
onChange={(e) =>
setConfig((c) => ({
...c,
page: {
...(c.page ?? {}),
margins: { ...(c.page?.margins ?? {}), [side]: Number(e.target.value) || 0 },
},
}))
}
/>
</div>
))}
</div>
</div>
</div>
{/* Watermark */}
<div className="flex items-center gap-3 border border-secondary-200 rounded-lg p-3">
<label className="flex items-center gap-2 text-sm text-secondary-700">
<input
type="checkbox"
checked={config.watermark?.enabled ?? false}
onChange={(e) => setConfig((c) => ({ ...c, watermark: { enabled: e.target.checked, text: c.watermark?.text ?? '' } }))}
/>
{t('documents.watermark', 'Wasserzeichen')}
</label>
{config.watermark?.enabled && (
<Input
value={config.watermark.text ?? ''}
onChange={(e) => setConfig((c) => ({ ...c, watermark: { enabled: true, text: e.target.value } }))}
placeholder={t('documents.watermarkText', 'z.B. ENTWURF')}
aria-label={t('documents.watermarkText', 'Wasserzeichen-Text')}
/>
)}
</div>
{/* Logo/Asset upload */}
<div className="border border-secondary-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-secondary-500 uppercase">{t('documents.assets', 'Bilder & Logos')}</p>
{letterhead && (
<label className="cursor-pointer text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1" data-testid="letterhead-asset-upload">
<Upload className="w-4 h-4" aria-hidden="true" />
{t('documents.asset.upload', 'Bild hochladen')}
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/svg+xml,image/webp"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const asset = await uploadAsset.mutateAsync({ letterheadId: letterhead.id, file });
toast.success(t('documents.asset.uploaded', 'Bild hochgeladen (ID in Zwischenablage)'));
await navigator.clipboard?.writeText(asset.id).catch(() => undefined);
} catch {
toast.error(t('documents.asset.uploadFailed', 'Upload fehlgeschlagen'));
}
e.target.value = '';
}}
/>
</label>
)}
</div>
{!letterhead && (
<p className="text-xs text-secondary-400">{t('documents.asset.saveFirst', 'Briefpapier erst speichern, dann Bilder hochladen.')}</p>
)}
{letterhead && assetsLoading && <Skeleton className="h-8 w-full" />}
{letterhead && !assetsLoading && assets.length === 0 && (
<p className="text-xs text-secondary-400">{t('documents.asset.empty', 'Noch keine Bilder.')}</p>
)}
{assets.length > 0 && (
<ul className="space-y-1">
{assets.map((a) => (
<li key={a.id} className="flex items-center gap-2 text-xs">
{a.data_url && <img src={a.data_url} alt={a.filename} className="w-6 h-6 object-contain" />}
<span className="truncate">{a.filename}</span>
<code className="text-secondary-400 truncate flex-1" title={a.id}>{a.id.slice(0, 8)}</code>
<span className="text-secondary-400">{(a.size_bytes / 1024).toFixed(0)} KB</span>
</li>
))}
</ul>
)}
</div>
{/* Header/Footer block editors */}
<div className="flex gap-2" role="tablist" aria-label="Briefpapier-Bereiche">
{(['header', 'footer'] as const).map((sec) => (
<button
key={sec}
role="tab"
aria-selected={section === sec}
onClick={() => setSection(sec)}
className={`px-3 py-1.5 rounded-md text-sm font-medium ${
section === sec ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100'
}`}
>
{sec === 'header' ? t('documents.header', 'Kopfzeile') : t('documents.footer', 'Fußzeile')}
{' '}
<label className="ml-2 inline-flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={config[sec]?.enabled ?? false}
onChange={(e) =>
setConfig((c) => ({ ...c, [sec]: { ...(c[sec] ?? { blocks: [] }), enabled: e.target.checked } }))
}
aria-label={sec === 'header' ? t('documents.header', 'Kopfzeile') : t('documents.footer', 'Fußzeile')}
/>
{t('common.active', 'aktiv')}
</label>
</button>
))}
</div>
{config[section]?.enabled && (
<BlockEditor
blocks={config[section]?.blocks ?? []}
onChange={setSectionBlocks}
letterheadConfig={config}
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-100">
<Button variant="secondary" onClick={onClose}>{t('common.cancel', 'Abbrechen')}</Button>
<Button onClick={handleSave} isLoading={createMutation.isPending || updateMutation.isPending} data-testid="letterhead-save">
{t('common.save', 'Speichern')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,165 @@
/**
* PrintTemplateEditor Druckvorlagen-Editor (Phase L2).
*
* Combines a letterhead reference, an entity type (selects the module's
* placeholder palette) and the drag&drop block composition (BlockEditor).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useToast } from '@/components/ui/Toast';
import {
useCreatePrintTemplate,
useUpdatePrintTemplate,
useDocumentPlaceholders,
type DocBlock,
type Letterhead,
type PrintTemplate,
} from '@/api/documents';
import { BlockEditor } from './BlockEditor';
interface PrintTemplateEditorProps {
template: PrintTemplate | null;
letterheads: Letterhead[];
onClose: () => void;
}
export function PrintTemplateEditor({ template, letterheads, onClose }: PrintTemplateEditorProps) {
const { t } = useTranslation();
const toast = useToast();
const [name, setName] = useState(template?.name ?? '');
const [description, setDescription] = useState(template?.description ?? '');
const [letterheadId, setLetterheadId] = useState<string>(template?.letterhead_id ?? '');
const [entityType, setEntityType] = useState<string>(template?.entity_type ?? 'contact');
const [blocks, setBlocks] = useState<DocBlock[]>(template?.blocks ?? []);
const createMutation = useCreatePrintTemplate();
const updateMutation = useUpdatePrintTemplate();
// discover available entity types from the placeholder registry
const { data: placeholderMap } = useDocumentPlaceholders();
const entityTypes = useMemo(() => Object.keys(placeholderMap ?? {}).sort(), [placeholderMap]);
useEffect(() => {
if (template) {
setName(template.name);
setDescription(template.description);
setLetterheadId(template.letterhead_id ?? '');
setEntityType(template.entity_type);
setBlocks(template.blocks ?? []);
}
}, [template]);
const selectedLetterhead = useMemo(
() => letterheads.find((l) => l.id === letterheadId) ?? null,
[letterheads, letterheadId]
);
const handleSave = async () => {
if (!name.trim()) {
toast.error(t('documents.template.nameRequired', 'Name ist erforderlich'));
return;
}
const input = {
name: name.trim(),
description,
letterhead_id: letterheadId || null,
entity_type: entityType,
blocks,
output_format: 'pdf' as const,
};
try {
if (template) {
await updateMutation.mutateAsync({ id: template.id, input });
toast.success(t('documents.template.saved', 'Vorlage gespeichert'));
} else {
await createMutation.mutateAsync(input);
toast.success(t('documents.template.created', 'Vorlage angelegt'));
}
onClose();
} catch (e) {
toast.error(t('documents.template.saveFailed', 'Speichern fehlgeschlagen — Blöcke prüfen'));
}
};
return (
<Modal
open
onClose={onClose}
title={template ? t('documents.template.edit', 'Vorlage bearbeiten') : t('documents.template.create', 'Neue Druckvorlage')}
size="xl"
>
<div className="space-y-4" data-testid="print-template-editor">
{/* Meta */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-name">{t('common.name', 'Name')}</label>
<Input id="tpl-name" value={name} onChange={(e) => setName(e.target.value)} data-testid="template-name-input" />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-desc">{t('common.description', 'Beschreibung')}</label>
<Input id="tpl-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-letterhead">{t('documents.letterhead', 'Briefpapier')}</label>
<select
id="tpl-letterhead"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={letterheadId}
onChange={(e) => setLetterheadId(e.target.value)}
data-testid="template-letterhead-select"
>
<option value="">{t('documents.template.noLetterhead', 'Ohne Briefpapier')}</option>
{letterheads.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
{/* Entity type */}
<div className="max-w-xs">
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-entity">{t('documents.template.entityType', 'Datenquelle (Modul)')}</label>
<select
id="tpl-entity"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={entityType}
onChange={(e) => setEntityType(e.target.value)}
data-testid="template-entity-type-select"
>
{(entityTypes.length > 0 ? entityTypes : ['contact']).map((et) => (
<option key={et} value={et}>{et}</option>
))}
</select>
<p className="text-[10px] text-secondary-400 mt-1">
{t('documents.template.entityTypeHint', 'Bestimmt die verfügbaren Platzhalter (Modul-Beitrag)')}
</p>
</div>
{/* Block editor (content blocks + letterhead frame preview) */}
<BlockEditor
blocks={blocks}
onChange={setBlocks}
entityType={entityType}
letterheadConfig={selectedLetterhead?.config ?? null}
/>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-100">
<Button variant="secondary" onClick={onClose}>{t('common.cancel', 'Abbrechen')}</Button>
<Button
onClick={handleSave}
isLoading={createMutation.isPending || updateMutation.isPending}
data-testid="template-save"
>
{t('common.save', 'Speichern')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -128,6 +128,7 @@ const STATIC_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })), '@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })), '@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
'@/pages/DedupMergePage': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })), '@/pages/DedupMergePage': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then(normalizeModule),
'@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule), '@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule),
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule), '@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule), '@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),
+95 -1
View File
@@ -331,7 +331,8 @@
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus", "livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
"resetTheme": "Zurücksetzen", "resetTheme": "Zurücksetzen",
"saveTheme": "Theme speichern", "saveTheme": "Theme speichern",
"mcp": "MCP" "mcp": "MCP",
"documents": "Dokumente"
}, },
"auditLog": { "auditLog": {
"title": "Audit-Log", "title": "Audit-Log",
@@ -1466,5 +1467,98 @@
"failed": "fehlgeschlagen", "failed": "fehlgeschlagen",
"importDone": "Import abgeschlossen", "importDone": "Import abgeschlossen",
"backgroundRunning": "Import läuft im Hintergrund …" "backgroundRunning": "Import läuft im Hintergrund …"
},
"documents": {
"settings": {
"subtitle": "Briefpapiere und Druckvorlagen mit Drag&Drop-Editor verwalten",
"tabs": "Dokumente-Bereiche"
},
"letterheads": "Briefpapiere",
"printTemplates": "Druckvorlagen",
"letterhead": {
"create": "Neues Briefpapier",
"edit": "Briefpapier bearbeiten",
"empty": "Noch kein Briefpapier angelegt.",
"default": "Standard",
"deleted": "Briefpapier gelöscht",
"deleteConfirm": "Briefpapier wirklich löschen?",
"nameRequired": "Name ist erforderlich",
"saved": "Briefpapier gespeichert",
"created": "Briefpapier angelegt",
"isDefault": "Als Standard-Briefpapier verwenden"
},
"template": {
"create": "Neue Vorlage",
"edit": "Vorlage bearbeiten",
"empty": "Noch keine Druckvorlage angelegt.",
"deleted": "Vorlage gelöscht",
"deleteConfirm": "Vorlage wirklich löschen?",
"nameRequired": "Name ist erforderlich",
"saved": "Vorlage gespeichert",
"created": "Vorlage angelegt",
"saveFailed": "Speichern fehlgeschlagen — Blöcke prüfen",
"noLetterhead": "Ohne Briefpapier",
"entityType": "Datenquelle (Modul)",
"entityTypeHint": "Bestimmt die verfügbaren Platzhalter (Modul-Beitrag)"
},
"page": {
"size": "Seitenformat",
"orientation": "Ausrichtung",
"portrait": "Hochformat",
"landscape": "Querformat",
"margins": "Seitenränder (mm)"
},
"watermark": "Wasserzeichen",
"watermarkText": "z.B. ENTWURF",
"header": "Kopfzeile",
"footer": "Fußzeile",
"assets": "Bilder & Logos",
"asset": {
"upload": "Bild hochladen",
"uploaded": "Bild hochgeladen (ID in Zwischenablage)",
"uploadFailed": "Upload fehlgeschlagen",
"empty": "Noch keine Bilder.",
"saveFirst": "Briefpapier erst speichern, dann Bilder hochladen."
},
"editor": {
"palette": "Blöcke",
"canvas": "Reihenfolge",
"configPreview": "Konfiguration & Vorschau",
"empty": "Blöcke aus der Palette hinzufügen",
"selectBlock": "Block in der Reihenfolge-Liste auswählen, um ihn zu konfigurieren.",
"content": "Inhalt",
"placeholdersHint": "{{platzhalter}} möglich",
"bold": "Fett",
"italic": "Kursiv",
"shape": "Form",
"line": "Linie",
"rect": "Rechteck",
"circle": "Kreis",
"width": "Breite",
"height": "Höhe (px)",
"color": "Farbe",
"background": "Füllung",
"thickness": "Dicke (px)",
"assetId": "Asset-ID (Briefpapier-Upload)",
"align": "Ausrichtung",
"left": "Links",
"center": "Zentriert",
"right": "Rechts",
"columns": "Spalten (Komma-getrennt)",
"rows": "Zeilen (JSON)",
"placeholderKey": "Datenfeld",
"label": "Anzeige-Label",
"preview": "Live-Vorschau",
"pagebreakHint": "Erzwingt einen Seitenwechsel im PDF.",
"noConfig": "Keine Konfiguration für diesen Block-Typ."
},
"dialog": {
"title": "Dokument erstellen",
"for": "Für",
"generate": "Erstellen",
"generated": "Dokument wurde erstellt",
"generateFailed": "Dokument konnte nicht erstellt werden",
"noTemplates": "Keine Druckvorlagen für diesen Datentyp. Vorlagen unter Einstellungen → Dokumente anlegen."
}
} }
} }
+95 -1
View File
@@ -331,7 +331,8 @@
"livePreviewDescription": "This is how the app looks with the current theme", "livePreviewDescription": "This is how the app looks with the current theme",
"resetTheme": "Reset", "resetTheme": "Reset",
"saveTheme": "Save theme", "saveTheme": "Save theme",
"mcp": "MCP" "mcp": "MCP",
"documents": "Documents"
}, },
"auditLog": { "auditLog": {
"title": "Audit Log", "title": "Audit Log",
@@ -1466,5 +1467,98 @@
"failed": "failed", "failed": "failed",
"importDone": "Import finished", "importDone": "Import finished",
"backgroundRunning": "Import running in background …" "backgroundRunning": "Import running in background …"
},
"documents": {
"settings": {
"subtitle": "Manage letterheads and print templates with the drag&drop editor",
"tabs": "Document sections"
},
"letterheads": "Letterheads",
"printTemplates": "Print Templates",
"letterhead": {
"create": "New Letterhead",
"edit": "Edit Letterhead",
"empty": "No letterhead yet.",
"default": "Default",
"deleted": "Letterhead deleted",
"deleteConfirm": "Delete this letterhead?",
"nameRequired": "Name is required",
"saved": "Letterhead saved",
"created": "Letterhead created",
"isDefault": "Use as default letterhead"
},
"template": {
"create": "New Template",
"edit": "Edit Template",
"empty": "No print template yet.",
"deleted": "Template deleted",
"deleteConfirm": "Delete this template?",
"nameRequired": "Name is required",
"saved": "Template saved",
"created": "Template created",
"saveFailed": "Save failed — check blocks",
"noLetterhead": "Without letterhead",
"entityType": "Data source (module)",
"entityTypeHint": "Determines available placeholders (module contribution)"
},
"page": {
"size": "Page size",
"orientation": "Orientation",
"portrait": "Portrait",
"landscape": "Landscape",
"margins": "Margins (mm)"
},
"watermark": "Watermark",
"watermarkText": "e.g. DRAFT",
"header": "Header",
"footer": "Footer",
"assets": "Images & Logos",
"asset": {
"upload": "Upload image",
"uploaded": "Image uploaded (ID copied to clipboard)",
"uploadFailed": "Upload failed",
"empty": "No images yet.",
"saveFirst": "Save the letterhead first, then upload images."
},
"editor": {
"palette": "Blocks",
"canvas": "Order",
"configPreview": "Configuration & Preview",
"empty": "Add blocks from the palette",
"selectBlock": "Select a block in the order list to configure it.",
"content": "Content",
"placeholdersHint": "{{placeholder}} supported",
"bold": "Bold",
"italic": "Italic",
"shape": "Shape",
"line": "Line",
"rect": "Rectangle",
"circle": "Circle",
"width": "Width",
"height": "Height (px)",
"color": "Color",
"background": "Fill",
"thickness": "Thickness (px)",
"assetId": "Asset ID (letterhead upload)",
"align": "Alignment",
"left": "Left",
"center": "Center",
"right": "Right",
"columns": "Columns (comma-separated)",
"rows": "Rows (JSON)",
"placeholderKey": "Data field",
"label": "Display label",
"preview": "Live Preview",
"pagebreakHint": "Forces a page break in the PDF.",
"noConfig": "No configuration for this block type."
},
"dialog": {
"title": "Create Document",
"for": "For",
"generate": "Generate",
"generated": "Document created",
"generateFailed": "Document could not be created",
"noTemplates": "No print templates for this data type. Create templates under Settings → Documents."
}
} }
} }
+24 -2
View File
@@ -2,7 +2,7 @@
* Contact Detail Page loads a single contact by ID from route params. * Contact Detail Page loads a single contact by ID from route params.
*/ */
import React from 'react'; import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ContactDetail } from '@/components/contacts/ContactDetail'; import { ContactDetail } from '@/components/contacts/ContactDetail';
@@ -10,8 +10,9 @@ import { ContactEditForm } from '@/components/contacts/ContactEditForm';
import { useWindowStore } from '@/store/windowStore'; import { useWindowStore } from '@/store/windowStore';
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks'; import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft, FileText } from 'lucide-react';
import { PrintButton } from '@/components/common/PrintButton'; import { PrintButton } from '@/components/common/PrintButton';
import { DocumentGenerationDialog } from '@/components/documents/DocumentGenerationDialog';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
@@ -23,6 +24,7 @@ export function ContactDetailPage() {
const openWindow = useWindowStore((s) => s.openWindow); const openWindow = useWindowStore((s) => s.openWindow);
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const authUser = useAuthStore((state) => state.user); const authUser = useAuthStore((state) => state.user);
const [docDialogOpen, setDocDialogOpen] = useState(false);
const canAccess = (perm: string): boolean => { const canAccess = (perm: string): boolean => {
return hasPermission(perm); return hasPermission(perm);
}; };
@@ -59,6 +61,17 @@ export function ContactDetailPage() {
</button> </button>
<div className="flex-1" /> <div className="flex-1" />
{canAccess('contacts:read') && <PrintButton targetId="contact-detail" />} {canAccess('contacts:read') && <PrintButton targetId="contact-detail" />}
{canAccess('reports:generate') && (
<button
onClick={() => setDocDialogOpen(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('documents.dialog.title', 'Dokument erstellen')}
title={t('documents.dialog.title', 'Dokument erstellen')}
data-testid="contact-detail-document-button"
>
<FileText className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
)}
</div> </div>
<div className="flex-1 overflow-y-auto" id="contact-detail"> <div className="flex-1 overflow-y-auto" id="contact-detail">
<ContactDetail <ContactDetail
@@ -68,6 +81,15 @@ export function ContactDetailPage() {
onDeleted={handleDeleted} onDeleted={handleDeleted}
/> />
</div> </div>
{contact && (
<DocumentGenerationDialog
open={docDialogOpen}
onClose={() => setDocDialogOpen(false)}
entityType={contact.type === 'company' ? 'company' : 'contact'}
entityId={contact.id}
entityLabel={contact.displayname}
/>
)}
</div> </div>
); );
} }
+225
View File
@@ -0,0 +1,225 @@
/**
* Document Settings Verwaltungsoberfläche für den Dokumente-Generator
* (Phase L): Briefpapiere + Druckvorlagen mit Drag&Drop-Block-Editor.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileText, Layers } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import {
useLetterheads,
usePrintTemplates,
useCreateLetterhead,
useDeleteLetterhead,
useCreatePrintTemplate,
useDeletePrintTemplate,
} from '@/api/documents';
import { LetterheadEditor } from '@/components/documents/LetterheadEditor';
import { PrintTemplateEditor } from '@/components/documents/PrintTemplateEditor';
import type { Letterhead, PrintTemplate } from '@/api/documents';
export function DocumentSettingsPage() {
const { t } = useTranslation();
const toast = useToast();
const [tab, setTab] = useState<'letterheads' | 'templates'>('letterheads');
const [editingLetterhead, setEditingLetterhead] = useState<Letterhead | 'new' | null>(null);
const [editingTemplate, setEditingTemplate] = useState<PrintTemplate | 'new' | null>(null);
const { data: letterheads = [], isLoading: lhLoading } = useLetterheads();
const { data: templates = [], isLoading: tplLoading } = usePrintTemplates();
const createLh = useCreateLetterhead();
const deleteLh = useDeleteLetterhead();
const createTpl = useCreatePrintTemplate();
const deleteTpl = useDeletePrintTemplate();
return (
<div className="max-w-6xl mx-auto space-y-6" data-testid="document-settings-page">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-primary-600" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.documents', 'Dokumente')}</h1>
<p className="text-sm text-secondary-500">
{t('documents.settings.subtitle', 'Briefpapiere und Druckvorlagen mit Drag&Drop-Editor verwalten')}
</p>
</div>
</div>
<div className="flex gap-2" role="tablist" aria-label={t('documents.settings.tabs', 'Dokumente-Bereiche')}>
<button
role="tab"
aria-selected={tab === 'letterheads'}
onClick={() => setTab('letterheads')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'letterheads' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100'
}`}
data-testid="documents-tab-letterheads"
>
{t('documents.letterheads', 'Briefpapiere')} ({letterheads.length})
</button>
<button
role="tab"
aria-selected={tab === 'templates'}
onClick={() => setTab('templates')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'templates' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100'
}`}
data-testid="documents-tab-templates"
>
{t('documents.printTemplates', 'Druckvorlagen')} ({templates.length})
</button>
</div>
{tab === 'letterheads' && (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-secondary-900">{t('documents.letterheads', 'Briefpapiere')}</h2>
<Button
size="sm"
icon={<FileText className="w-4 h-4" aria-hidden="true" />}
onClick={() => setEditingLetterhead('new')}
data-testid="documents-create-letterhead"
>
{t('documents.letterhead.create', 'Neues Briefpapier')}
</Button>
</div>
{lhLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : letterheads.length === 0 ? (
<p className="text-sm text-secondary-500 py-8 text-center">
{t('documents.letterhead.empty', 'Noch kein Briefpapier angelegt.')}
</p>
) : (
<ul className="divide-y divide-secondary-100" data-testid="documents-letterhead-list">
{letterheads.map((lh) => (
<li key={lh.id} className="flex items-center justify-between py-3 gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900 truncate">{lh.name}</span>
{lh.is_default && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary-100 text-primary-700">
{t('documents.letterhead.default', 'Standard')}
</span>
)}
</div>
<p className="text-xs text-secondary-500 truncate">{lh.description || '—'}</p>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="secondary" onClick={() => setEditingLetterhead(lh)}>
{t('common.edit', 'Bearbeiten')}
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
if (!window.confirm(t('documents.letterhead.deleteConfirm', 'Briefpapier wirklich löschen?'))) return;
try {
await deleteLh.mutateAsync(lh.id);
toast.success(t('documents.letterhead.deleted', 'Briefpapier gelöscht'));
} catch (e) {
toast.error(t('common.error', 'Fehler beim Löschen'));
}
}}
aria-label={t('common.delete', 'Löschen')}
>
{t('common.delete', 'Löschen')}
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
)}
{tab === 'templates' && (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-secondary-900">{t('documents.printTemplates', 'Druckvorlagen')}</h2>
<Button
size="sm"
icon={<Layers className="w-4 h-4" aria-hidden="true" />}
onClick={() => setEditingTemplate('new')}
data-testid="documents-create-template"
>
{t('documents.template.create', 'Neue Vorlage')}
</Button>
</div>
{tplLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-secondary-500 py-8 text-center">
{t('documents.template.empty', 'Noch keine Druckvorlage angelegt.')}
</p>
) : (
<ul className="divide-y divide-secondary-100" data-testid="documents-template-list">
{templates.map((tpl) => {
const lh = letterheads.find((l) => l.id === tpl.letterhead_id);
return (
<li key={tpl.id} className="flex items-center justify-between py-3 gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900 truncate">{tpl.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary-100 text-secondary-600">
{tpl.entity_type}
</span>
<span className="text-xs text-secondary-400">{tpl.blocks.length} Blöcke</span>
</div>
<p className="text-xs text-secondary-500 truncate">
{lh ? `${t('documents.letterhead', 'Briefpapier')}: ${lh.name}` : t('documents.template.noLetterhead', 'Ohne Briefpapier')}
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="secondary" onClick={() => setEditingTemplate(tpl)}>
{t('common.edit', 'Bearbeiten')}
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
if (!window.confirm(t('documents.template.deleteConfirm', 'Vorlage wirklich löschen?'))) return;
try {
await deleteTpl.mutateAsync(tpl.id);
toast.success(t('documents.template.deleted', 'Vorlage gelöscht'));
} catch (e) {
toast.error(t('common.error', 'Fehler beim Löschen'));
}
}}
aria-label={t('common.delete', 'Löschen')}
>
{t('common.delete', 'Löschen')}
</Button>
</div>
</li>
);
})}
</ul>
)}
</Card>
)}
{editingLetterhead && (
<LetterheadEditor
letterhead={editingLetterhead === 'new' ? null : editingLetterhead}
onClose={() => setEditingLetterhead(null)}
/>
)}
{editingTemplate && (
<PrintTemplateEditor
template={editingTemplate === 'new' ? null : editingTemplate}
letterheads={letterheads}
onClose={() => setEditingTemplate(null)}
/>
)}
</div>
);
}
+2 -1
View File
@@ -5,7 +5,7 @@ import { usePluginStore } from '@/store/pluginStore';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { Settings, Mail, Bell, Sparkles, Bot, Shield, Users, UsersRound, Package, ArrowLeft } from 'lucide-react'; import { Settings, Mail, Bell, Sparkles, Bot, Shield, Users, UsersRound, Package, ArrowLeft, FileText } from 'lucide-react';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = { const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Settings, Settings,
@@ -16,6 +16,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Shield, Shield,
Users, Users,
UsersRound, UsersRound,
FileText,
}; };
const FALLBACK_ICON = Package; const FALLBACK_ICON = Package;
+2
View File
@@ -52,6 +52,7 @@ const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashb
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage }))); const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage }))); const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage }))); const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
const DocumentSettingsPage = React.lazy(() => import('@/pages/DocumentSettings').then(m => ({ default: m.DocumentSettingsPage })));
const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage }))); const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage })));
const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage }))); const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage })));
const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage }))); const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage })));
@@ -223,6 +224,7 @@ const router = createBrowserRouter([
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) }, { path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) }, { path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) }, { path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
{ path: 'documents', element: withSuspense(<DocumentSettingsPage />) },
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) }, { path: 'backup', element: withSuspense(<SettingsBackupPage />) },
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> }, { path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> }, { path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
+759
View File
@@ -0,0 +1,759 @@
"""Documents Generator tests (Phase L1-L3) — letterheads, print templates,
block registry, module contributions, preview/render, RBAC, isolation.
Contract-Muster (like importexport_entities): plugins contribute
``document_blocks()``, ``document_placeholders(entity_type)`` and
``document_data(db, tenant_id, entity_id, entity_type)``. contacts is the
first contributor (W4a-style contribution, #359 philosophy).
"""
from __future__ import annotations
import uuid as uuid_mod
from collections.abc import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from app.core.db import close_engine, reset_engine_for_testing
from app.core.permission_registry import init_permission_registry
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.report_generator import ReportGeneratorPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import (
ORIGIN_HEADER,
login_client,
seed_tenant_and_users,
)
DOC_BASE = "/api/v1/reports"
@pytest_asyncio.fixture
async def docs_app(engine: AsyncEngine, redis_client):
"""App with permissions + report_generator installed & activated."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
init_permission_registry(active_plugin_names={"permissions", "report_generator"})
container = get_container()
await container.initialize()
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(ReportGeneratorPlugin())
# contacts contributes document placeholders/data via its contract —
# register (not install) so list_discovered() includes it and the
# contract registry can lazy-load contacts.contracts.
from app.plugins.builtins.contacts.plugin import ContactsPlugin
registry.register_plugin(ContactsPlugin())
reset_plugin_service_for_testing(registry)
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with _sf() as session:
await registry.install(session, "permissions")
await registry.activate(session, "permissions")
await registry.install(session, "report_generator")
await registry.activate(session, "report_generator")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def docs_client(docs_app) -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=docs_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
def _letterhead_payload(**overrides) -> dict:
payload = {
"name": "Standard Briefpapier",
"description": "Firmenbriefkopf mit Logo",
"config": {
"page": {
"size": "A4",
"orientation": "portrait",
"margins": {"top": 25, "right": 20, "bottom": 25, "left": 20},
},
"header": {
"enabled": True,
"blocks": [
{
"id": "h1",
"type": "text",
"config": {"content": "Meine Firma GmbH"},
}
],
},
"footer": {"enabled": True, "blocks": []},
"watermark": {"enabled": False},
},
"is_default": False,
}
payload.update(overrides)
return payload
def _template_payload(letterhead_id: str | None = None, **overrides) -> dict:
payload = {
"name": "Angebot",
"description": "Angebot mit Briefpapier",
"letterhead_id": letterhead_id,
"entity_type": "company",
"blocks": [
{
"id": "b1",
"type": "text",
"config": {"content": "Sehr geehrte Damen und Herren,\n\nFirma: {{name}}"},
},
{
"id": "b2",
"type": "shape",
"config": {"shape": "line", "width": "100%", "height": 1, "color": "#111827"},
},
],
"output_format": "pdf",
}
payload.update(overrides)
return payload
# ─── Letterhead CRUD ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestLetterheadCRUD:
async def test_create_letterhead_201(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, f"{resp.status_code} {resp.text}"
body = resp.json()
assert body["name"] == "Standard Briefpapier"
assert body["config"]["header"]["enabled"] is True
assert body["id"]
async def test_create_letterhead_empty_name_422(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(name=""),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
async def test_list_letterheads_tenant_scoped(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
lh_a = resp.json()["id"]
# Tenant B admin creates their own letterhead
await login_client(docs_client, "admin@tenantb.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(name="Tenant B Kopf"),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
resp = await docs_client.get(f"{DOC_BASE}/letterheads", headers=ORIGIN_HEADER)
assert resp.status_code == 200
items = resp.json()["items"]
ids = [i["id"] for i in items]
assert lh_a not in ids # cross-tenant isolation
assert len(items) == 1
assert items[0]["name"] == "Tenant B Kopf"
async def test_get_letterhead_404(self, docs_client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.get(
f"{DOC_BASE}/letterheads/{uuid_mod.uuid4()}", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
async def test_update_letterhead(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
lh_id = resp.json()["id"]
resp = await docs_client.put(
f"{DOC_BASE}/letterheads/{lh_id}",
json={"name": "Neuer Name"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "Neuer Name"
async def test_delete_letterhead_soft(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
lh_id = resp.json()["id"]
resp = await docs_client.delete(
f"{DOC_BASE}/letterheads/{lh_id}", headers=ORIGIN_HEADER
)
assert resp.status_code == 204
resp = await docs_client.get(
f"{DOC_BASE}/letterheads/{lh_id}", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
async def test_viewer_cannot_manage_403(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "viewer@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403
# ─── Print Template CRUD ─────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestPrintTemplateCRUD:
async def test_create_template_201(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, f"{resp.status_code} {resp.text}"
body = resp.json()
assert body["name"] == "Angebot"
assert len(body["blocks"]) == 2
assert body["entity_type"] == "company"
async def test_create_template_with_letterhead_ref(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
lh_id = resp.json()["id"]
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(letterhead_id=lh_id),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["letterhead_id"] == lh_id
async def test_create_template_invalid_block_422(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(blocks=[{"id": "x", "type": "not_a_block"}]),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
assert "invalid_block" in resp.text
async def test_create_template_text_block_requires_content_422(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(
blocks=[{"id": "x", "type": "text", "config": {}}]
),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
async def test_create_template_bad_shape_422(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(
blocks=[
{
"id": "x",
"type": "shape",
"config": {"shape": "triangle"},
}
]
),
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
async def test_list_and_update_and_delete_template(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(),
headers=ORIGIN_HEADER,
)
tid = resp.json()["id"]
resp = await docs_client.get(f"{DOC_BASE}/print-templates", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert any(t["id"] == tid for t in resp.json()["items"])
resp = await docs_client.put(
f"{DOC_BASE}/print-templates/{tid}",
json={"name": "Angebot v2"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "Angebot v2"
resp = await docs_client.delete(
f"{DOC_BASE}/print-templates/{tid}", headers=ORIGIN_HEADER
)
assert resp.status_code == 204
resp = await docs_client.get(
f"{DOC_BASE}/print-templates/{tid}", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
# ─── Block Registry + Module Contributions ──────────────────────────────────
@pytest.mark.asyncio
class TestBlockRegistry:
async def test_document_blocks_contains_builtin_types(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.get(f"{DOC_BASE}/document-blocks", headers=ORIGIN_HEADER)
assert resp.status_code == 200
types = {b["type"] for b in resp.json()}
assert {
"text",
"image",
"shape",
"table",
"spacer",
"divider",
"placeholder",
} <= types
# builtin entries carry a label for the palette UI
assert all(b.get("label") for b in resp.json())
async def test_document_placeholders_contains_contacts_contribution(
self, docs_client: AsyncClient, db_session
):
"""contacts plugin contributes placeholders via contract (L1)."""
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.get(
f"{DOC_BASE}/document-placeholders", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert "contact" in data, f"contact placeholders missing: {list(data)}"
keys = [p["key"] for p in data["contact"]]
assert "firstname" in keys
assert "displayname" in keys
# every placeholder has label + example for the editor UI
assert all(p.get("label") and p.get("example") is not None for p in data["contact"])
# ─── Preview (HTML) ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestPreview:
async def test_preview_renders_blocks_with_data(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/documents/preview",
json={
"blocks": [
{
"id": "b1",
"type": "text",
"config": {"content": "Hallo {{displayname}}!"},
}
],
"letterhead_config": None,
"data": {"displayname": "Test AG"},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, f"{resp.status_code} {resp.text}"
html = resp.json()["html"]
assert "Hallo Test AG!" in html
async def test_preview_with_letterhead_header(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/documents/preview",
json={
"blocks": [
{
"id": "b1",
"type": "text",
"config": {"content": "Inhalt"},
}
],
"letterhead_config": _letterhead_payload()["config"],
"data": {},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
html = resp.json()["html"]
assert "Meine Firma GmbH" in html
assert "@page" in html or "running(" in html
async def test_preview_placeholder_defaults_without_data(
self, docs_client: AsyncClient, db_session
):
"""Missing data keys fall back to placeholder example values."""
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/documents/preview",
json={
"blocks": [
{
"id": "b1",
"type": "text",
"config": {"content": "{{firstname}}"},
}
],
"letterhead_config": None,
"entity_type": "contact",
"data": None,
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# must render the example value, not crash with StrictUndefined
assert "{{firstname}}" not in resp.json()["html"]
async def test_preview_invalid_block_422(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/documents/preview",
json={
"blocks": [{"id": "x", "type": "nope"}],
"letterhead_config": None,
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
# ─── Render (PDF) ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestRender:
async def _create_template(self, client: AsyncClient, blocks=None) -> str:
payload = _template_payload()
if blocks is not None:
payload["blocks"] = blocks
resp = await client.post(
f"{DOC_BASE}/print-templates",
json=payload,
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, f"{resp.status_code} {resp.text}"
return resp.json()["id"]
async def test_render_pdf_bytes(
self, docs_client: AsyncClient, db_session
):
seed = await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
tid = await self._create_template(docs_client)
resp = await docs_client.post(
f"{DOC_BASE}/print-templates/{tid}/render",
json={
"entity_type": "company",
"entity_id": str(seed["company_a"].id),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, f"{resp.status_code} {resp.text}"
assert resp.headers["content-type"].startswith("application/pdf")
assert resp.content.startswith(b"%PDF")
async def test_render_with_letterhead_pdf(
self, docs_client: AsyncClient, db_session
):
seed = await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
lh_id = resp.json()["id"]
resp = await docs_client.post(
f"{DOC_BASE}/print-templates",
json=_template_payload(letterhead_id=lh_id),
headers=ORIGIN_HEADER,
)
tid = resp.json()["id"]
resp = await docs_client.post(
f"{DOC_BASE}/print-templates/{tid}/render",
json={
"entity_type": "company",
"entity_id": str(seed["company_a"].id),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
async def test_render_unknown_entity_404(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
tid = await self._create_template(docs_client)
resp = await docs_client.post(
f"{DOC_BASE}/print-templates/{tid}/render",
json={"entity_type": "company", "entity_id": str(uuid_mod.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
async def test_render_template_not_found_404(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates/{uuid_mod.uuid4()}/render",
json={"entity_type": "company", "entity_id": str(uuid_mod.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
async def test_render_viewer_forbidden_403(
self, docs_client: AsyncClient, db_session
):
seed = await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
tid = await self._create_template(docs_client)
await login_client(docs_client, "viewer@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/print-templates/{tid}/render",
json={
"entity_type": "company",
"entity_id": str(seed["company_a"].id),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403
async def test_render_image_block_missing_asset_no_crash(
self, docs_client: AsyncClient, db_session
):
"""image block referencing a non-existent asset renders empty."""
seed = await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
tid = await self._create_template(
docs_client,
blocks=[
{
"id": "img1",
"type": "image",
"config": {"asset_id": str(uuid_mod.uuid4()), "width": 100},
},
{
"id": "t1",
"type": "text",
"config": {"content": "{{name}}"},
},
],
)
resp = await docs_client.post(
f"{DOC_BASE}/print-templates/{tid}/render",
json={
"entity_type": "company",
"entity_id": str(seed["company_a"].id),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
# ─── Assets (Logo/Bild-Upload) ───────────────────────────────────────────────
@pytest.mark.asyncio
class TestAssets:
async def test_upload_logo_asset_201(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
lh_id = resp.json()["id"]
# minimal valid PNG (1x1 transparent)
png_bytes = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c626001000000ffff03000006000557bfabd40000000049454e44ae426082"
)
resp = await docs_client.post(
f"{DOC_BASE}/letterheads/{lh_id}/assets",
files={"file": ("logo.png", png_bytes, "image/png")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, f"{resp.status_code} {resp.text}"
body = resp.json()
assert body["filename"] == "logo.png"
assert body["mime_type"] == "image/png"
assert body["id"]
async def test_upload_non_image_422(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads",
json=_letterhead_payload(),
headers=ORIGIN_HEADER,
)
lh_id = resp.json()["id"]
resp = await docs_client.post(
f"{DOC_BASE}/letterheads/{lh_id}/assets",
files={"file": ("notes.txt", b"not an image", "text/plain")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
async def test_upload_to_foreign_letterhead_404(
self, docs_client: AsyncClient, db_session
):
await seed_tenant_and_users(db_session)
await login_client(docs_client, "admin@tenanta.com")
resp = await docs_client.post(
f"{DOC_BASE}/letterheads/{uuid_mod.uuid4()}/assets",
files={"file": ("x.png", b"x", "image/png")},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
# ─── Contacts Contract Contribution (Unit) ───────────────────────────────────
@pytest.mark.asyncio
class TestContactsDocumentContribution:
async def test_document_placeholders_contact(self):
from app.plugins.builtins.contacts.contracts import ContactsContract
placeholders = ContactsContract.document_placeholders("contact")
assert isinstance(placeholders, list)
keys = [p["key"] for p in placeholders]
assert "firstname" in keys and "email" in keys
async def test_document_placeholders_unknown_entity_empty(self):
from app.plugins.builtins.contacts.contracts import ContactsContract
assert ContactsContract.document_placeholders("warehouse") == []
async def test_document_data_loads_entity(self, db_session):
seed = await seed_tenant_and_users(db_session)
from app.plugins.builtins.contacts.contracts import ContactsContract
data = await ContactsContract.document_data(
db_session,
seed["tenant_a"].id,
seed["company_a"].id,
"company",
)
assert data["name"] == "Company Alpha"
async def test_document_data_unknown_entity_empty(self, db_session):
seed = await seed_tenant_and_users(db_session)
from app.plugins.builtins.contacts.contracts import ContactsContract
data = await ContactsContract.document_data(
db_session,
seed["tenant_a"].id,
uuid_mod.uuid4(),
"company",
)
assert data == {}