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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user