From 7f7c7cc3c7ad9631229fbde62dbd0351f35088b0 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:02:00 -0700 Subject: [PATCH] Support custom fields in workflow title templates --- docs/usage.md | 1 + src/documents/consumer.py | 13 ++++++ src/documents/templating/workflows.py | 6 +++ src/documents/tests/test_workflows.py | 44 +++++++++++++++++++++ src/documents/workflows/mutations.py | 57 ++++++++++++++------------- 5 files changed, 94 insertions(+), 27 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ba41c0d6e..c4c407414 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}}` (title assignments only): 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..265429d8f 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: 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,