From 9c4c9f238682e59d12650069bb11e121643cc4e6 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:40:53 -0700 Subject: [PATCH] Enhancement: support custom fields in workflow title templates (#14564) --- docs/usage.md | 1 + src/documents/consumer.py | 13 +++ src/documents/templating/workflows.py | 6 ++ src/documents/tests/test_workflows.py | 110 ++++++++++++++++++++++++++ src/documents/workflows/actions.py | 22 ++++++ src/documents/workflows/mutations.py | 57 ++++++------- 6 files changed, 182 insertions(+), 27 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ba41c0d6e..12d2fcb2b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -726,6 +726,7 @@ applied. You can use the following placeholders in the template with any trigger - `{{added_time}}`: added time in HH:MM format - `{{original_filename}}`: original file name without extension - `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`) +- `{{custom_fields}}`: Custom field names mapped to their type and value, as in [file name templates](advanced_usage.md#additional-variables). For example, `{{ custom_fields.Invoice.value }}` or `{{ custom_fields | get_cf_value("Invoice", "") }}` for a fallback when the field is missing. In title assignments, fields assigned by the same action are available. - `{{doc_title}}`: current document title (cannot be used in title assignment) The following placeholders are only available for "added" or "updated" triggers diff --git a/src/documents/consumer.py b/src/documents/consumer.py index dc63f9aef..94efd5f69 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -847,6 +847,19 @@ class ConsumerPlugin( local_added, self.filename, self.filename, + custom_fields=[ + CustomFieldInstance( + field=field, + **{ + CustomFieldInstance.get_value_field_name( + field.data_type, + ): self.metadata.custom_fields[field.pk], + }, + ) + for field in CustomField.objects.filter( + pk__in=self.metadata.custom_fields or {}, + ) + ], ) def _store( diff --git a/src/documents/templating/workflows.py b/src/documents/templating/workflows.py index f1c94913a..913d4f4b6 100644 --- a/src/documents/templating/workflows.py +++ b/src/documents/templating/workflows.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Iterable from datetime import date from datetime import datetime from pathlib import Path @@ -13,7 +14,9 @@ from jinja2 import make_logging_undefined from jinja2.meta import find_undeclared_variables from jinja2.sandbox import SecurityError +from documents.models import CustomFieldInstance from documents.templating.environment import _template_environment +from documents.templating.filepath import get_custom_fields_context from documents.templating.filters import format_datetime from documents.templating.filters import localize_date @@ -56,6 +59,7 @@ _known_placeholder_names = { "doc_title", "doc_url", "doc_id", + "custom_fields", } @@ -86,6 +90,7 @@ def parse_w_workflow_placeholders( doc_title: str | None = None, doc_url: str | None = None, doc_id: int | None = None, + custom_fields: Iterable[CustomFieldInstance] = (), ) -> str: """ Available title placeholders for Workflows depend on what has already been assigned, @@ -108,6 +113,7 @@ def parse_w_workflow_placeholders( "original_filename": Path(original_filename).stem, "filename": Path(filename).stem, } + formatting.update(get_custom_fields_context(custom_fields)) if created is not None: formatting.update( { diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 7d7b5c080..506fa6872 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -1846,6 +1846,50 @@ class TestWorkflows( expected_str = f"Document storage path {doc.storage_path} does not match {trigger.filter_has_storage_path}" self.assertIn(expected_str, cm.output[1]) + def test_title_custom_fields(self) -> None: + from documents.consumer import ConsumerPlugin + from documents.templating.workflows import validate_workflow_template + + field = CustomField.objects.create( + name="Invoice", + data_type=CustomField.FieldDataType.STRING, + ) + template = '{{ custom_fields.Invoice.value }} {{ custom_fields | get_cf_value("Missing", "fallback") }}' + validate_workflow_template(template) + consumer = mock.Mock( + filename="invoice.pdf", + metadata=DocumentMetadataOverrides(custom_fields={field.pk: "INV-123"}), + ) + self.assertEqual( + ConsumerPlugin._parse_title_placeholders(consumer, template), + "INV-123 fallback", + ) + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + action = WorkflowAction.objects.create(assign_title=template) + doc = Document.objects.create(title="Original") + action.assign_custom_fields.add(field) + action.assign_custom_fields_values = {str(field.pk): "INV-123"} + action.save() + workflow = Workflow.objects.create(name="Custom field title", order=0) + workflow.triggers.add(trigger) + workflow.actions.add(action) + + document_consumption_finished.send(sender=self.__class__, document=doc) + + doc.refresh_from_db() + self.assertEqual(doc.title, "INV-123 fallback") + + # Existing fields remain available when the action assigns no custom fields. + action.assign_custom_fields.clear() + CustomFieldInstance.objects.filter(document=doc, field=field).update( + value_text="INV-456", + ) + document_consumption_finished.send(sender=self.__class__, document=doc) + doc.refresh_from_db() + self.assertEqual(doc.title, "INV-456 fallback") + def test_document_added_invalid_title_placeholders(self) -> None: """ GIVEN: @@ -5120,6 +5164,72 @@ class TestWorkflows( self.assertEqual(Document.objects.count(), 0) +@pytest.mark.django_db +@pytest.mark.parametrize("use_overrides", [False, True]) +def test_custom_fields_in_email_and_webhook_templates( + use_overrides, + settings, + tmp_path, +): + from documents.workflows.actions import build_workflow_action_context + from documents.workflows.actions import execute_email_action + from documents.workflows.actions import execute_webhook_action + + settings.EMAIL_ENABLED = True + field = CustomField.objects.create( + name="Invoice", + data_type=CustomField.FieldDataType.STRING, + ) + if use_overrides: + invoice = tmp_path / "invoice.pdf" + invoice.write_bytes(b"%PDF-1.7") + document = ConsumableDocument( + source=DocumentSource.ConsumeFolder, + original_file=invoice, + ) + overrides = DocumentMetadataOverrides(custom_fields={field.pk: "INV-123"}) + else: + document = Document.objects.create(title="Invoice") + CustomFieldInstance.objects.create( + document=document, + field=field, + value_text="INV-123", + ) + overrides = None + context = build_workflow_action_context(document, overrides) + template = '{{ custom_fields.Invoice.value }} {{ custom_fields | get_cf_value("Missing", "fallback") }}' + action = WorkflowAction( + email=WorkflowActionEmail( + subject=template, + body=template, + to="test@example.com", + ), + webhook=WorkflowActionWebhook( + url="https://example.com", + body=template, + ), + ) + with mock.patch("documents.workflows.actions.send_email") as send_email: + execute_email_action( + action, + document, + context, + None, + Path("invoice.pdf"), + WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + assert send_email.call_args.kwargs["subject"] == "INV-123 fallback" + assert send_email.call_args.kwargs["body"] == "INV-123 fallback" + with mock.patch("documents.workflows.actions.send_webhook.apply_async") as send: + for use_params in (False, True): + action.webhook.use_params = use_params + action.webhook.params = {"invoice": template} + execute_webhook_action(action, document, context, None, Path("invoice.pdf")) + assert send.call_args.kwargs["kwargs"]["data"] == ( + {"invoice": "INV-123 fallback"} if use_params else "INV-123 fallback" + ) + + class TestWebhookSend: def test_send_webhook_data_or_json( self, diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index 5a2e0c6be..f6011126f 100644 --- a/src/documents/workflows/actions.py +++ b/src/documents/workflows/actions.py @@ -11,6 +11,8 @@ from documents.data_models import DocumentMetadataOverrides from documents.mail import EmailAttachment from documents.mail import send_email from documents.models import Correspondent +from documents.models import CustomField +from documents.models import CustomFieldInstance from documents.models import Document from documents.models import DocumentType from documents.models import WorkflowAction @@ -48,6 +50,9 @@ def build_workflow_action_context( "added": timezone.localtime(document.added), "created": document.created, "id": document.pk, + "custom_fields": CustomFieldInstance.global_objects.filter( + document=document, + ).select_related("field"), } correspondent_obj = ( @@ -80,6 +85,19 @@ def build_workflow_action_context( "added": timezone.localtime(timezone.now()), "created": overrides.created if overrides else None, "id": "", + "custom_fields": [ + CustomFieldInstance( + field=field, + **{ + CustomFieldInstance.get_value_field_name( + field.data_type, + ): overrides.custom_fields[field.pk], + }, + ) + for field in CustomField.objects.filter( + pk__in=overrides.custom_fields or {}, + ) + ], } @@ -116,6 +134,7 @@ def execute_email_action( context["title"], context["doc_url"], context["id"], + custom_fields=context["custom_fields"], ) if action.email.subject else "" @@ -133,6 +152,7 @@ def execute_email_action( context["title"], context["doc_url"], context["id"], + custom_fields=context["custom_fields"], ) if action.email.body else "" @@ -211,6 +231,7 @@ def execute_webhook_action( context["title"], context["doc_url"], context["id"], + custom_fields=context["custom_fields"], ) except Exception as e: logger.error( @@ -230,6 +251,7 @@ def execute_webhook_action( context["title"], context["doc_url"], context["id"], + custom_fields=context["custom_fields"], ) headers = {} if action.webhook.headers: diff --git a/src/documents/workflows/mutations.py b/src/documents/workflows/mutations.py index 07b608cef..33982ef5f 100644 --- a/src/documents/workflows/mutations.py +++ b/src/documents/workflows/mutations.py @@ -43,6 +43,33 @@ def apply_assignment_to_document( if action.assign_owner: document.owner = action.assign_owner + if action.has_assign_custom_fields: + for field in action.assign_custom_fields.all(): + value_field_name = CustomFieldInstance.get_value_field_name( + data_type=field.data_type, + ) + args = { + value_field_name: action.assign_custom_fields_values.get( + str(field.pk), + None, + ), + } + # for some reason update_or_create doesn't work here + instance = CustomFieldInstance.objects.filter( + field=field, + document=document, + ).first() + # empty string is indistinguishable from no value in the UI + if instance and args[value_field_name] not in (None, ""): + setattr(instance, value_field_name, args[value_field_name]) + instance.save() + elif not instance: + CustomFieldInstance.objects.create( + **args, + field=field, + document=document, + ) + if action.assign_title: try: title = parse_w_workflow_placeholders( @@ -57,6 +84,9 @@ def apply_assignment_to_document( "", # dont pass the title to avoid recursion "", # no urls in titles document.pk, + custom_fields=CustomFieldInstance.global_objects.filter( + document=document, + ).select_related("field"), ) if title: document.title = title @@ -91,33 +121,6 @@ def apply_assignment_to_document( merge=True, ) - if action.has_assign_custom_fields: - for field in action.assign_custom_fields.all(): - value_field_name = CustomFieldInstance.get_value_field_name( - data_type=field.data_type, - ) - args = { - value_field_name: action.assign_custom_fields_values.get( - str(field.pk), - None, - ), - } - # for some reason update_or_create doesn't work here - instance = CustomFieldInstance.objects.filter( - field=field, - document=document, - ).first() - # empty string is indistinguishable from no value in the UI - if instance and args[value_field_name] not in (None, ""): - setattr(instance, value_field_name, args[value_field_name]) - instance.save() - elif not instance: - CustomFieldInstance.objects.create( - **args, - field=field, - document=document, - ) - def apply_assignment_to_overrides( action: WorkflowAction,