Compare commits

..
2 Commits
Author SHA1 Message Date
shamoon 2c329255a9 email and webhooks too 2026-10-26 21:07:00 -07:00
shamoon 7f7c7cc3c7 Support custom fields in workflow title templates 2026-10-26 21:02:00 -07:00
6 changed files with 182 additions and 27 deletions

No files matched your search

+1
View File
@@ -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 - `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension - `{{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}}`) - `{{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) - `{{doc_title}}`: current document title (cannot be used in title assignment)
The following placeholders are only available for "added" or "updated" triggers The following placeholders are only available for "added" or "updated" triggers
+13
View File
@@ -847,6 +847,19 @@ class ConsumerPlugin(
local_added, local_added,
self.filename, self.filename,
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( def _store(
+6
View File
@@ -1,4 +1,5 @@
import logging import logging
from collections.abc import Iterable
from datetime import date from datetime import date
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -13,7 +14,9 @@ from jinja2 import make_logging_undefined
from jinja2.meta import find_undeclared_variables from jinja2.meta import find_undeclared_variables
from jinja2.sandbox import SecurityError from jinja2.sandbox import SecurityError
from documents.models import CustomFieldInstance
from documents.templating.environment import _template_environment 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 format_datetime
from documents.templating.filters import localize_date from documents.templating.filters import localize_date
@@ -56,6 +59,7 @@ _known_placeholder_names = {
"doc_title", "doc_title",
"doc_url", "doc_url",
"doc_id", "doc_id",
"custom_fields",
} }
@@ -86,6 +90,7 @@ def parse_w_workflow_placeholders(
doc_title: str | None = None, doc_title: str | None = None,
doc_url: str | None = None, doc_url: str | None = None,
doc_id: int | None = None, doc_id: int | None = None,
custom_fields: Iterable[CustomFieldInstance] = (),
) -> str: ) -> str:
""" """
Available title placeholders for Workflows depend on what has already been assigned, 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, "original_filename": Path(original_filename).stem,
"filename": Path(filename).stem, "filename": Path(filename).stem,
} }
formatting.update(get_custom_fields_context(custom_fields))
if created is not None: if created is not None:
formatting.update( formatting.update(
{ {
+110
View File
@@ -1846,6 +1846,50 @@ class TestWorkflows(
expected_str = f"Document storage path {doc.storage_path} does not match {trigger.filter_has_storage_path}" expected_str = f"Document storage path {doc.storage_path} does not match {trigger.filter_has_storage_path}"
self.assertIn(expected_str, cm.output[1]) 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: def test_document_added_invalid_title_placeholders(self) -> None:
""" """
GIVEN: GIVEN:
@@ -5120,6 +5164,72 @@ class TestWorkflows(
self.assertEqual(Document.objects.count(), 0) 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: class TestWebhookSend:
def test_send_webhook_data_or_json( def test_send_webhook_data_or_json(
self, self,
+22
View File
@@ -11,6 +11,8 @@ from documents.data_models import DocumentMetadataOverrides
from documents.mail import EmailAttachment from documents.mail import EmailAttachment
from documents.mail import send_email from documents.mail import send_email
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import WorkflowAction from documents.models import WorkflowAction
@@ -48,6 +50,9 @@ def build_workflow_action_context(
"added": timezone.localtime(document.added), "added": timezone.localtime(document.added),
"created": document.created, "created": document.created,
"id": document.pk, "id": document.pk,
"custom_fields": CustomFieldInstance.global_objects.filter(
document=document,
).select_related("field"),
} }
correspondent_obj = ( correspondent_obj = (
@@ -80,6 +85,19 @@ def build_workflow_action_context(
"added": timezone.localtime(timezone.now()), "added": timezone.localtime(timezone.now()),
"created": overrides.created if overrides else None, "created": overrides.created if overrides else None,
"id": "", "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["title"],
context["doc_url"], context["doc_url"],
context["id"], context["id"],
custom_fields=context["custom_fields"],
) )
if action.email.subject if action.email.subject
else "" else ""
@@ -133,6 +152,7 @@ def execute_email_action(
context["title"], context["title"],
context["doc_url"], context["doc_url"],
context["id"], context["id"],
custom_fields=context["custom_fields"],
) )
if action.email.body if action.email.body
else "" else ""
@@ -211,6 +231,7 @@ def execute_webhook_action(
context["title"], context["title"],
context["doc_url"], context["doc_url"],
context["id"], context["id"],
custom_fields=context["custom_fields"],
) )
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -230,6 +251,7 @@ def execute_webhook_action(
context["title"], context["title"],
context["doc_url"], context["doc_url"],
context["id"], context["id"],
custom_fields=context["custom_fields"],
) )
headers = {} headers = {}
if action.webhook.headers: if action.webhook.headers:
+30 -27
View File
@@ -43,6 +43,33 @@ def apply_assignment_to_document(
if action.assign_owner: if action.assign_owner:
document.owner = 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: if action.assign_title:
try: try:
title = parse_w_workflow_placeholders( title = parse_w_workflow_placeholders(
@@ -57,6 +84,9 @@ def apply_assignment_to_document(
"", # dont pass the title to avoid recursion "", # dont pass the title to avoid recursion
"", # no urls in titles "", # no urls in titles
document.pk, document.pk,
custom_fields=CustomFieldInstance.global_objects.filter(
document=document,
).select_related("field"),
) )
if title: if title:
document.title = title document.title = title
@@ -91,33 +121,6 @@ def apply_assignment_to_document(
merge=True, 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( def apply_assignment_to_overrides(
action: WorkflowAction, action: WorkflowAction,