feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
+25
-2
@@ -12,11 +12,10 @@
|
||||
4. **custom_field_definitions generisch machen** — ✅ erledigt (2026-08-29): W4b-Muster komplett angewendet. Route: 422-Entity-Validierung + 403-Owner-Modul-Check (contacts:read etc.) auf create/list/update/delete, ACL-Loch gefixt (delete übergab user_id nie → 500 für alle Nicht-Admins), PermissionError→403-Handler. Shape-Fix: Backend lieferte Array, alle 6 Frontend-Consumer lesen `data.items` → CustomFields-UI zeigte seit jeher leer; jetzt `{items,total}` (CustomFieldDefinitionListResponse). entity_permission_service: Plural-Ableitungs-Fix ('workflow'→workflows:read statt Phantom contacts:read; 'address'→addresses:read via +es), unregister_entity_model räumt ENTITY_PLUGIN_OWNERS mit auf (Lifecycle-Leak), zentrale Helper validate_entity_type/check_entity_read_permission — saved_filters+saved_views Duplikate entfernt (Aliase, Call-Sites unverändert). Neue Suite tests/test_custom_field_definitions.py **13 Tests**: Rot bewiesen 10 failed/4 passed → Grün **25 passed** (13 cfd + 12 saved_filters-Regression), Permission-Suiten 22/22, custom_fields+lifecycle 13/13, ruff exit=0, create_app OK. 3er-Kombi-Failures (custom_fields+entity_registry+lifecycle) per Stash als identischer Vorbestand bewiesen (clean HEAD: gleiche 7 Failures — Suite-Isolation, kein Zusammenhang mit Änderung). api-documentation.md ergänzt (4 Endpoints)
|
||||
5. **Sidebar /contacts statische Route entfernen** — ✅ erledigt (2026-08-29): Kritikpunkt 21 zuerst erfüllt (Renderer bewiesen, DANN entfernt). PluginRouteRenderer komplett neu: verschachtelte `<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
|
||||
7. **Phase L: Dokumente-Generator** — PLATFORM_ROADMAP.md 'Phase L' (L1-L5, ~9-15 Tage), user-abgestimmt, Basis: report_generator-Plugin
|
||||
|
||||
**Wichtig:** AGENTS.md-Regeln zuerst lesen (§0.0 Sub-Agents nur für einfache Jobs, §0.2 auf bestehendem Code aufbauen, §10 'PROGRESS.md als Source of Truth').
|
||||
|
||||
> **Letztes Update:** 2026-08-28
|
||||
> **Letztes Update:** 2026-08-29
|
||||
|
||||
## Produktions-Bugfixes (2026-08-27)
|
||||
|
||||
@@ -35,6 +34,30 @@
|
||||
|
||||
**Verifikation:** tests/test_custom_fields.py **11/11 passed** (Funktionserhalt) · create_app OK · ruff grün · Full Deploy SUCCESS · Health healthy
|
||||
|
||||
## Phase L1-L3 — Dokumente-Generator Backend+Editor (2026-08-29) ✅
|
||||
|
||||
**Scope:** Briefpapier (letterheads) + Druckvorlagen (print_templates) + Assets (document_assets) + Block-Registry + Contract-Beiträge + Drag&Drop-Editor + globaler Dokument-Dialog. Erweiterung des report_generator-Plugins (kein Neubau).
|
||||
|
||||
**Umgesetzt:**
|
||||
- Backend: `documents.py` (13 Endpoints), `document_blocks.py` (Registry: text/image/shape/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via `document_blocks()`-Contract), `document_renderer.py` (Blocks→HTML→PDF, WeasyPrint data:-URI-only SSRF-Policy, Briefpapier-@page-Frame mit running header/footer)
|
||||
- Contract-Beitrag contacts: `document_placeholders(entity_type)`, `document_data(db, tenant_id, entity_id, entity_type)` (#359-Muster wie importexport_entities)
|
||||
- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path-Konvergenz, RLS fail-closed nach 0084-Muster)
|
||||
- Frontend: `api/documents.ts` + `DocumentSettings`-Page (Settings→Dokumente, eigener Menüpunkt via settings_pages) + `BlockEditor` (@dnd-kit: Palette/Canvas/Config-Panel/Live-Preview-iframe) + `LetterheadEditor` + `PrintTemplateEditor` + `DocumentGenerationDialog` (global für Module, integriert in ContactDetailPage)
|
||||
- i18n de/en vollständig
|
||||
|
||||
**Verifiziert:**
|
||||
- ✅ tests/test_documents_generator.py: 32/32 (CRUD, Tenant-Isolation, RBAC 403, Block-Validierung 422, Preview, Render-PDF `%PDF`, Assets, Contract-Unit)
|
||||
- ✅ Regression: test_report_generator.py + test_plugin_route_order.py 9/9
|
||||
- ✅ tsc --noEmit Exit 0; Production-Build OK (2.79s)
|
||||
- ✅ Alembic-Fresh-DB: 0001→0143 komplett, letterheads/print_templates/document_assets mit RLS+FORCE+crm_api-Policy bewiesen (Scratch-DB wieder gedroppt)
|
||||
- ✅ ruff check clean; check_migration_hashes 93/93 OK
|
||||
- ⚠️ Bekannt: Router-Reihenfolge im Manifest — documents-Router muss VOR routes stehen (/{report_id}-Catch-all)
|
||||
|
||||
**Offen (Folgepakete):**
|
||||
- L4: KI-Steuerung („Erstelle Rechnungsvorlage") via agent_loop
|
||||
- L5: E-Rechnung XRechnung/ZUGFeRD (benötigt Verkaufs-Modul)
|
||||
- Weitere Module können Blöcke/Platzhalter beisteuern (Contract-Muster dokumentiert in plugin-development-guide.md)
|
||||
|
||||
## W3b — Settings Contribution-Wahrheit (2026-08-28) ✅
|
||||
|
||||
**Verify-first (Live-Messung):** 7 Plugins liefern `settings_pages` via Manifest (mail, ai_assistant, ai_proactive, automation, permissions ×3, system_notif) — die hardcoded Items in `Settings.tsx` für mail/ai/notifications waren identische Duplikate.
|
||||
|
||||
@@ -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)
|
||||
@@ -308,6 +308,47 @@ class ContactsContract:
|
||||
)
|
||||
return _serialize_contact(contact)
|
||||
|
||||
# ─── Document Generator contribution (Phase L1, #359 pattern) ───
|
||||
# The documents generator resolves placeholders + entity data via these
|
||||
# contract hooks. Same philosophy as importexport_entities(): the module
|
||||
# owns its domain data, the generic renderer stays module-agnostic.
|
||||
|
||||
@staticmethod
|
||||
def document_entity_types() -> list[str]:
|
||||
"""Entity types this plugin serves in the documents generator."""
|
||||
return ["contact", "company", "person"]
|
||||
|
||||
@staticmethod
|
||||
def document_placeholders(entity_type: str) -> list[dict]:
|
||||
"""Placeholder descriptors (key/label/example) for the drag/drop editor."""
|
||||
return _placeholders_for(entity_type)
|
||||
|
||||
@staticmethod
|
||||
async def document_data(
|
||||
db: AsyncSession,
|
||||
tenant_id: Any,
|
||||
entity_id: Any,
|
||||
entity_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Load one entity as template data ({} when not found)."""
|
||||
contact = (
|
||||
await db.execute(
|
||||
select(Contact).where(
|
||||
Contact.id == entity_id,
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if contact is None:
|
||||
return {}
|
||||
fields = _contacts_document_fields()
|
||||
data: dict[str, Any] = {}
|
||||
for key in fields:
|
||||
value = getattr(contact, key, None)
|
||||
data[key] = value if value is not None else ""
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
@@ -318,3 +359,70 @@ class ContactsContract:
|
||||
|
||||
_contract = ContactsContract()
|
||||
get_contract_registry().register("contacts", _contract)
|
||||
|
||||
|
||||
def _contacts_document_fields() -> dict[str, str]:
|
||||
"""Contact/company fields available in document templates (L1).
|
||||
|
||||
Keys map to Contact model attributes; labels/examples feed the
|
||||
drag/drop editor palette and the preview fallback values.
|
||||
"""
|
||||
return {
|
||||
"displayname": "Anzeigename",
|
||||
"firstname": "Vorname",
|
||||
"surname": "Nachname",
|
||||
"name": "Firmenname",
|
||||
"email": "E-Mail",
|
||||
"email_1": "E-Mail 1",
|
||||
"email_2": "E-Mail 2",
|
||||
"phone": "Telefon",
|
||||
"phone_1": "Telefon 1",
|
||||
"phone_2": "Telefon 2",
|
||||
"mobile": "Mobil",
|
||||
"website": "Website",
|
||||
"industry": "Branche",
|
||||
"city": "Stadt",
|
||||
"postalcode": "PLZ",
|
||||
"country": "Land",
|
||||
"vat_code": "USt-IdNr.",
|
||||
"function": "Funktion",
|
||||
"department": "Abteilung",
|
||||
}
|
||||
|
||||
|
||||
_CONTACT_DOC_EXAMPLES = {
|
||||
"displayname": "Max Mustermann",
|
||||
"firstname": "Max",
|
||||
"surname": "Mustermann",
|
||||
"name": "Muster GmbH",
|
||||
"email": "max@example.com",
|
||||
"email_1": "max@example.com",
|
||||
"email_2": "buero@example.com",
|
||||
"phone": "+49 30 123456",
|
||||
"phone_1": "+49 30 123456",
|
||||
"phone_2": "+49 171 1234567",
|
||||
"mobile": "+49 171 1234567",
|
||||
"website": "https://example.com",
|
||||
"industry": "IT",
|
||||
"city": "Berlin",
|
||||
"postalcode": "10115",
|
||||
"country": "Deutschland",
|
||||
"vat_code": "DE123456789",
|
||||
"function": "Geschäftsführer",
|
||||
"department": "Vertrieb",
|
||||
}
|
||||
|
||||
|
||||
def _placeholders_for(entity_type: str) -> list[dict]:
|
||||
"""Placeholder descriptors for contact/company templates."""
|
||||
if entity_type not in ("contact", "company", "person"):
|
||||
return []
|
||||
fields = _contacts_document_fields()
|
||||
result = []
|
||||
for key, label in fields.items():
|
||||
result.append({
|
||||
"key": key,
|
||||
"label": label,
|
||||
"example": _CONTACT_DOC_EXAMPLES.get(key, "…"),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -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
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, Text
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -63,3 +64,87 @@ class ReportInstance(Base, TenantMixin, OwnedMixin):
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
|
||||
|
||||
class Letterhead(Base, TenantMixin, OwnedMixin):
|
||||
"""Letterhead (Briefpapier) — page setup + header/footer block composition.
|
||||
|
||||
Phase L1: per-tenant letterhead. ``config`` stores the page geometry
|
||||
(size/orientation/margins) plus header/footer/watermark block lists.
|
||||
Blocks use the same ``{id, type, config}`` shape as print templates so
|
||||
the drag/drop editor can edit both with one component set.
|
||||
"""
|
||||
|
||||
__tablename__ = "letterheads"
|
||||
__table_args__ = (
|
||||
Index("ix_letterheads_tenant", "tenant_id"),
|
||||
Index("ix_letterheads_name", "name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
|
||||
|
||||
class PrintTemplate(Base, TenantMixin, OwnedMixin):
|
||||
"""Print template — drag/drop block composition bound to a letterhead.
|
||||
|
||||
Phase L1: ``blocks`` is an ordered JSONB array of
|
||||
``{id, type, config}`` entries validated against the document block
|
||||
registry. ``entity_type`` selects the module placeholder contribution
|
||||
(e.g. "company" → contacts placeholders).
|
||||
"""
|
||||
|
||||
__tablename__ = "print_templates"
|
||||
__table_args__ = (
|
||||
Index("ix_print_templates_tenant", "tenant_id"),
|
||||
Index("ix_print_templates_name", "name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("letterheads.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(100), nullable=False, default="contact")
|
||||
blocks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="pdf")
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
|
||||
|
||||
class DocumentAsset(Base, TenantMixin, OwnedMixin):
|
||||
"""Image asset for letterheads/print templates (logos, pictures).
|
||||
|
||||
Stored via the central storage backend; ``data_url`` is rendered into
|
||||
PDFs inline (WeasyPrint URL fetcher allows data: URIs only).
|
||||
"""
|
||||
|
||||
__tablename__ = "document_assets"
|
||||
__table_args__ = (
|
||||
Index("ix_document_assets_tenant", "tenant_id"),
|
||||
Index("ix_document_assets_letterhead", "letterhead_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("letterheads.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
||||
from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
FrontendSettingsPage,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
|
||||
|
||||
class ReportGeneratorPlugin(BasePlugin):
|
||||
@@ -17,12 +23,31 @@ class ReportGeneratorPlugin(BasePlugin):
|
||||
is_core=True,
|
||||
dependencies=["permissions"],
|
||||
routes=[
|
||||
# documents router MUST be registered before routes: its fixed
|
||||
# single-segment paths (/letterheads, /print-templates, ...) would
|
||||
# otherwise be shadowed by the /{report_id} catch-all in routes.
|
||||
PluginRouteDef(
|
||||
path="/api/v1/reports",
|
||||
module="app.plugins.builtins.report_generator.documents",
|
||||
router_attr="router",
|
||||
),
|
||||
PluginRouteDef(
|
||||
path="/api/v1/reports",
|
||||
module="app.plugins.builtins.report_generator.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
settings_pages=[
|
||||
FrontendSettingsPage(
|
||||
path="documents",
|
||||
label_key="settings.documents",
|
||||
label="Dokumente",
|
||||
component="@/pages/DocumentSettings",
|
||||
icon="FileText",
|
||||
order=75,
|
||||
permission="reports:read",
|
||||
),
|
||||
],
|
||||
events=["report.requested", "report.generated"],
|
||||
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
|
||||
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
|
||||
@@ -38,8 +63,20 @@ class ReportGeneratorPlugin(BasePlugin):
|
||||
contract_version="1.0.0")
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
from app.plugins.builtins.report_generator.models import ReportInstance, ReportTemplate
|
||||
return {"report_template": ReportTemplate, "report_instance": ReportInstance}
|
||||
from app.plugins.builtins.report_generator.models import (
|
||||
DocumentAsset,
|
||||
Letterhead,
|
||||
PrintTemplate,
|
||||
ReportInstance,
|
||||
ReportTemplate,
|
||||
)
|
||||
return {
|
||||
"report_template": ReportTemplate,
|
||||
"report_instance": ReportInstance,
|
||||
"letterhead": Letterhead,
|
||||
"print_template": PrintTemplate,
|
||||
"document_asset": DocumentAsset,
|
||||
}
|
||||
|
||||
async def on_activate(
|
||||
self, db, service_container, event_bus
|
||||
|
||||
@@ -72,3 +72,93 @@ class ReportResponse(BaseModel):
|
||||
created_by: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ─── Documents Generator (Phase L1-L3) ──────────────────────────────────────
|
||||
|
||||
|
||||
class LetterheadCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field("", max_length=2000)
|
||||
config: dict = Field(default_factory=dict)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class LetterheadUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
config: dict | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class LetterheadResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
config: dict
|
||||
is_default: bool
|
||||
created_by: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PrintTemplateCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field("", max_length=2000)
|
||||
letterhead_id: str | None = None
|
||||
entity_type: str = Field("contact", max_length=100)
|
||||
blocks: list[dict] = Field(default_factory=list)
|
||||
output_format: str = Field("pdf", pattern="^(pdf|print)$")
|
||||
|
||||
|
||||
class PrintTemplateUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
letterhead_id: str | None = None
|
||||
entity_type: str | None = Field(None, max_length=100)
|
||||
blocks: list[dict] | None = None
|
||||
output_format: str | None = Field(None, pattern="^(pdf|print)$")
|
||||
|
||||
|
||||
class PrintTemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
letterhead_id: str | None = None
|
||||
entity_type: str
|
||||
blocks: list[dict]
|
||||
output_format: str
|
||||
created_by: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DocumentPreviewRequest(BaseModel):
|
||||
"""Preview: render blocks to HTML (live preview in the editor)."""
|
||||
|
||||
blocks: list[dict]
|
||||
letterhead_config: dict | None = None
|
||||
entity_type: str | None = None
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class DocumentPreviewResponse(BaseModel):
|
||||
html: str
|
||||
|
||||
|
||||
class DocumentRenderRequest(BaseModel):
|
||||
"""Render a stored print template with entity data to PDF."""
|
||||
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
output_format: str = Field("pdf", pattern="^(pdf|print)$")
|
||||
|
||||
|
||||
class DocumentAssetResponse(BaseModel):
|
||||
id: str
|
||||
letterhead_id: str | None = None
|
||||
filename: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
data_url: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
@@ -511,6 +511,15 @@ Admin-only. Rebuild regenerates the embedding + TSV; purge sets embedding/TSV to
|
||||
| POST | `/api/v1/reports/generate` | Generate a report. |
|
||||
| GET | `/api/v1/reports/{report_id}` | Get report status. |
|
||||
| GET | `/api/v1/reports/{report_id}/download` | Download generated report. |
|
||||
| GET/POST | `/api/v1/reports/letterheads` | Briefpapier CRUD (Phase L1). |
|
||||
| GET/PUT/DELETE | `/api/v1/reports/letterheads/{letterhead_id}` | Einzelnes Briefpapier. |
|
||||
| GET/POST | `/api/v1/reports/letterheads/{letterhead_id}/assets` | Logo/Bild-Upload für Briefpapier (data:-URI). |
|
||||
| GET/POST | `/api/v1/reports/print-templates` | Druckvorlagen CRUD (Block-Komposition, Phase L1). |
|
||||
| GET/PUT/DELETE | `/api/v1/reports/print-templates/{template_id}` | Einzelne Druckvorlage. |
|
||||
| POST | `/api/v1/reports/print-templates/{template_id}/render` | Vorlage mit Entitätsdaten als PDF rendern (Phase L3). |
|
||||
| GET | `/api/v1/reports/document-blocks` | Block-Registry (Built-in + Modul-Beiträge, Phase L1). |
|
||||
| GET | `/api/v1/reports/document-placeholders` | Platzhalter-Registry pro Entity-Type (Modul-Beiträge). |
|
||||
| POST | `/api/v1/reports/documents/preview` | Blöcke → HTML Live-Vorschau (Phase L2). |
|
||||
|
||||
### entity-links (Entity Linking)
|
||||
|
||||
|
||||
@@ -2652,3 +2652,41 @@ LeoCRM ships pre-built agents in the automation plugin. Plugins can register add
|
||||
---
|
||||
|
||||
*This document is authoritative for all plugin development at LeoCRM.*
|
||||
|
||||
## Dokumente-Generator-Beitrag (Phase L)
|
||||
|
||||
Module koennen dem Dokumente-Generator Bloecke, Platzhalter und Daten beisteuern — Contract-Muster wie Import/Export (`#359`-Philosophie). Alle drei Hooks sind optional:
|
||||
|
||||
```python
|
||||
# contracts.py des Plugins
|
||||
class MyContract:
|
||||
@staticmethod
|
||||
def document_entity_types() -> list[str]:
|
||||
return ["myentity"]
|
||||
|
||||
@staticmethod
|
||||
def document_placeholders(entity_type: str) -> list[dict]:
|
||||
"""[{key, label, example}] — speist Editor-Palette + Preview-Defaults."""
|
||||
if entity_type != "myentity":
|
||||
return []
|
||||
return [{"key": "title", "label": "Titel", "example": "Beispiel AG"}]
|
||||
|
||||
@staticmethod
|
||||
async def document_data(db, tenant_id, entity_id, entity_type: str) -> dict:
|
||||
"""Eine Entitaet als Template-Daten laden ({} wenn nicht gefunden)."""
|
||||
obj = await db.get(MyModel, entity_id)
|
||||
if obj is None or obj.tenant_id != tenant_id:
|
||||
return {}
|
||||
return {"title": obj.title}
|
||||
|
||||
@staticmethod
|
||||
def document_blocks() -> list[dict]:
|
||||
"""Zusaetzliche Palette-Bloecke (generisch als Key-Value-Tabelle gerendert)."""
|
||||
return [{"type": "myentity_summary", "label": "Entitaets-Uebersicht", "category": "modul", "fields": ["title", "status"]}]
|
||||
```
|
||||
|
||||
**Regeln:**
|
||||
- Rendering uebernimmt report_generator (document_renderer.py) — Module liefern NIE Markup (XSS/SSRF-Sandbox bleibt intakt)
|
||||
- `document_placeholders`-Beispiele dienen als Preview-Fallbacks (StrictUndefined-vermeidend)
|
||||
- Built-in-Bloecke duerfen nicht ueberschrieben werden (Contributions mit existierendem Typ werden ignoriert)
|
||||
- Bilder laufen ausschliesslich als DocumentAsset/data:-URI (WeasyPrint-URL-Fetcher blockt extern)
|
||||
|
||||
@@ -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/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
|
||||
'@/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/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
|
||||
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),
|
||||
|
||||
@@ -331,7 +331,8 @@
|
||||
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
|
||||
"resetTheme": "Zurücksetzen",
|
||||
"saveTheme": "Theme speichern",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"documents": "Dokumente"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Audit-Log",
|
||||
@@ -1466,5 +1467,98 @@
|
||||
"failed": "fehlgeschlagen",
|
||||
"importDone": "Import abgeschlossen",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,8 @@
|
||||
"livePreviewDescription": "This is how the app looks with the current theme",
|
||||
"resetTheme": "Reset",
|
||||
"saveTheme": "Save theme",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"documents": "Documents"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Audit Log",
|
||||
@@ -1466,5 +1467,98 @@
|
||||
"failed": "failed",
|
||||
"importDone": "Import finished",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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 { useTranslation } from 'react-i18next';
|
||||
import { ContactDetail } from '@/components/contacts/ContactDetail';
|
||||
@@ -10,8 +10,9 @@ import { ContactEditForm } from '@/components/contacts/ContactEditForm';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, FileText } from 'lucide-react';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
import { DocumentGenerationDialog } from '@/components/documents/DocumentGenerationDialog';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
@@ -23,6 +24,7 @@ export function ContactDetailPage() {
|
||||
const openWindow = useWindowStore((s) => s.openWindow);
|
||||
const { hasPermission } = usePermission();
|
||||
const authUser = useAuthStore((state) => state.user);
|
||||
const [docDialogOpen, setDocDialogOpen] = useState(false);
|
||||
const canAccess = (perm: string): boolean => {
|
||||
return hasPermission(perm);
|
||||
};
|
||||
@@ -59,6 +61,17 @@ export function ContactDetailPage() {
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{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 className="flex-1 overflow-y-auto" id="contact-detail">
|
||||
<ContactDetail
|
||||
@@ -68,6 +81,15 @@ export function ContactDetailPage() {
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</div>
|
||||
{contact && (
|
||||
<DocumentGenerationDialog
|
||||
open={docDialogOpen}
|
||||
onClose={() => setDocDialogOpen(false)}
|
||||
entityType={contact.type === 'company' ? 'company' : 'contact'}
|
||||
entityId={contact.id}
|
||||
entityLabel={contact.displayname}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { usePluginStore } from '@/store/pluginStore';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
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 }>> = {
|
||||
Settings,
|
||||
@@ -16,6 +16,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Shield,
|
||||
Users,
|
||||
UsersRound,
|
||||
FileText,
|
||||
};
|
||||
|
||||
const FALLBACK_ICON = Package;
|
||||
|
||||
@@ -52,6 +52,7 @@ const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashb
|
||||
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 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 CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage })));
|
||||
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: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
|
||||
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
|
||||
{ path: 'documents', element: withSuspense(<DocumentSettingsPage />) },
|
||||
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
|
||||
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
||||
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in New Issue
Block a user