Compare 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
11 changed files with 195 additions and 149 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
- `{{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
+13
View File
@@ -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(
-8
View File
@@ -1381,17 +1381,9 @@ def task_revoked_handler(
return
try:
close_old_connections()
reason = "Task revoked before completion."
if expired:
reason = "Task expired before execution."
elif terminated:
reason = "Task terminated during execution."
PaperlessTask.objects.filter(task_id=task_id).update(
status=PaperlessTask.Status.REVOKED,
date_done=timezone.now(),
result_data={
"reason": (reason + " Check worker logs for details."),
},
)
except Exception: # pragma: no cover
logger.exception("Updating PaperlessTask on revocation failed")
+6
View File
@@ -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(
{
-65
View File
@@ -46,7 +46,6 @@ from documents.models import UiSettings
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.search import get_backend
from documents.signals.handlers import run_workflows
from documents.tests.utils import ConsumeTaskMixin
from paperless_testing.dirs import DirectoriesMixin
@@ -67,70 +66,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.client.force_authenticate(user=self.user)
cache.clear()
def test_detail_save_indexes_final_workflow_state(self) -> None:
viewer = UserFactory()
grant_global(viewer, "view_document")
inbox = TagFactory(is_inbox_tag=True)
doc = DocumentFactory(owner=self.user, content="distinctiveword")
doc.tags.add(inbox)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(assign_title="Workflow title")
action.assign_view_users.add(viewer)
action.remove_view_users.add(viewer)
workflow = Workflow.objects.create(name="Update document")
workflow.triggers.add(trigger)
workflow.actions.add(action)
backend = get_backend()
backend.add_or_update(doc)
for action_type in (
WorkflowAction.WorkflowActionType.ASSIGNMENT,
WorkflowAction.WorkflowActionType.REMOVAL,
WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
):
with self.subTest(action_type=action_type):
grant = action_type == WorkflowAction.WorkflowActionType.ASSIGNMENT
action.type = action_type
action.save()
trigger.filter_has_not_tags.set([inbox] if grant else [])
trigger.filter_has_tags.set([] if grant else [inbox])
self.client.force_authenticate(self.user)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{"tags": [] if grant else [inbox.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.client.force_authenticate(viewer)
self.assertEqual(
self.client.get(f"/api/documents/{doc.pk}/").status_code,
status.HTTP_200_OK if grant else status.HTTP_404_NOT_FOUND,
)
# Check the index directly: the API's DB permission filtering
# would otherwise hide stale grants and trashed index entries.
self.assertEqual(
list(backend.search_ids("distinctiveword", user=viewer)),
[doc.pk] if grant else [],
)
if grant:
response = self.client.get(
"/api/documents/",
{"query": "distinctiveword AND title:workflow"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[result["id"] for result in response.data["results"]],
[doc.pk],
)
elif action_type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
self.assertFalse(Document.objects.filter(pk=doc.pk).exists())
self.assertEqual(
list(backend.search_ids("distinctiveword", user=None)),
[],
)
def testDocuments(self) -> None:
response = self.client.get("/api/documents/").data
-30
View File
@@ -429,36 +429,6 @@ class TestTaskRevokedHandler:
assert task.status == PaperlessTask.Status.REVOKED
assert task.date_done is not None
@pytest.mark.parametrize(
("expired", "terminated", "reason"),
[
(True, False, "Task expired before execution."),
(False, True, "Task terminated during execution."),
(False, False, "Task revoked before completion."),
],
)
def test_records_revocation_details(
self,
mocker,
expired,
terminated,
reason,
) -> None:
task = PaperlessTaskFactory(status=PaperlessTask.Status.PENDING)
request = mocker.MagicMock()
request.id = task.task_id
task_revoked_handler(
request=request,
expired=expired,
terminated=terminated,
)
task.refresh_from_db()
assert task.result_data == {
"reason": f"{reason} Check worker logs for details.",
}
def test_ignores_none_request(self) -> None:
"""task_revoked_handler must not raise when request is None."""
+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}"
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,
+2 -8
View File
@@ -1304,19 +1304,13 @@ class DocumentViewSet(
from documents.search import get_backend
get_backend().add_or_update(refreshed_doc)
document_updated.send(
sender=self.__class__,
document=refreshed_doc,
)
# Index the final workflow state, including deletion.
backend = get_backend()
final_doc = Document.objects.filter(pk=root_doc.pk).first()
if final_doc is None:
backend.remove(root_doc.pk)
else:
backend.add_or_update(final_doc)
return response
def list(self, request, *args, **kwargs):
+22
View File
@@ -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:
+30 -27
View File
@@ -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,
+11 -11
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-28 02:55+0000\n"
"POT-Creation-Date: 2026-10-24 21:11+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:483 documents/serialisers.py:840
#: documents/serialisers.py:2872 documents/views.py:360 documents/views.py:2730
#: documents/serialisers.py:2872 documents/views.py:360 documents/views.py:2724
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1694,7 +1694,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2958 documents/views.py:4780
#: documents/serialisers.py:2958 documents/views.py:4774
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1949,36 +1949,36 @@ msgstr ""
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:353 documents/views.py:2727
#: documents/views.py:353 documents/views.py:2721
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1665
#: documents/views.py:1659
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1676
#: documents/views.py:1670
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:1688
#: documents/views.py:1682
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:4796
#: documents/views.py:4790
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4843
#: documents/views.py:4837
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4909
#: documents/views.py:4903
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4923
#: documents/views.py:4917
msgid "The share link bundle is unavailable."
msgstr ""