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

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

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