diff --git a/docs/usage.md b/docs/usage.md index 864eab0c1..d0c749f8d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -408,7 +408,7 @@ Currently, there are three events that correspond to workflow trigger 'types': but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now be used for filtering. 3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching, - tags, doc type, or correspondent. + tags, doc type, correspondent or storage path. 4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive offsets will trigger after the date, negative offsets will trigger before). @@ -452,10 +452,11 @@ Workflows allow you to filter by: - File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for example, automatically assigning documents to different owners based on the upload directory. - Mail rule. Choosing this option will force 'mail fetch' to be the workflow source. -- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings. -- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags -- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type -- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent +- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. +- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags +- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type +- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent +- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path ### Workflow Actions diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 2155979d6..7163ba289 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -177,6 +177,7 @@ + } diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 015b40113..ec27d6c59 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -412,6 +412,9 @@ export class WorkflowEditDialogComponent filter_has_document_type: new FormControl( trigger.filter_has_document_type ), + filter_has_storage_path: new FormControl( + trigger.filter_has_storage_path + ), schedule_offset_days: new FormControl(trigger.schedule_offset_days), schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), schedule_recurring_interval_days: new FormControl( @@ -536,6 +539,7 @@ export class WorkflowEditDialogComponent filter_has_tags: [], filter_has_correspondent: null, filter_has_document_type: null, + filter_has_storage_path: null, matching_algorithm: MATCH_NONE, match: '', is_insensitive: true, diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index 4299356b0..6e2d9cda7 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -44,6 +44,8 @@ export interface WorkflowTrigger extends ObjectWithId { filter_has_document_type?: number // DocumentType.id + filter_has_storage_path?: number // StoragePath.id + schedule_offset_days?: number schedule_is_recurring?: boolean diff --git a/src/documents/matching.py b/src/documents/matching.py index 346f9d55a..2088a6042 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -386,6 +386,16 @@ def existing_document_matches_workflow( ) trigger_matched = False + # Document storage_path vs trigger has_storage_path + if ( + trigger.filter_has_storage_path is not None + and document.storage_path != trigger.filter_has_storage_path + ): + reason = ( + f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}", + ) + trigger_matched = False + # Document original_filename vs trigger filename if ( trigger.filter_filename is not None @@ -430,6 +440,11 @@ def prefilter_documents_by_workflowtrigger( document_type=trigger.filter_has_document_type, ) + if trigger.filter_has_storage_path is not None: + documents = documents.filter( + storage_path=trigger.filter_has_storage_path, + ) + if trigger.filter_filename is not None and len(trigger.filter_filename) > 0: # the true fnmatch will actually run later so we just want a loose filter here regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$") diff --git a/src/documents/migrations/1069_migrate_workflow_title_jinja.py b/src/documents/migrations/1069_migrate_workflow_title_jinja.py deleted file mode 100644 index 52b701957..000000000 --- a/src/documents/migrations/1069_migrate_workflow_title_jinja.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 5.2.5 on 2025-08-27 22:02 -import logging - -from django.db import migrations -from django.db import models -from django.db import transaction - -from documents.templating.utils import convert_format_str_to_template_format - -logger = logging.getLogger("paperless.migrations") - - -def convert_from_format_to_template(apps, schema_editor): - WorkflowActions = apps.get_model("documents", "WorkflowAction") - - with transaction.atomic(): - for WorkflowAction in WorkflowActions.objects.all(): - WorkflowAction.assign_title = convert_format_str_to_template_format( - WorkflowAction.assign_title, - ) - logger.debug( - "Converted WorkflowAction id %d title to template format: %s", - WorkflowAction.id, - WorkflowAction.assign_title, - ) - WorkflowAction.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1068_alter_document_created"), - ] - - operations = [ - migrations.AlterField( - model_name="WorkflowAction", - name="assign_title", - field=models.TextField( - null=True, - blank=True, - help_text=( - "Assign a document title, can be a JINJA2 template, " - "see documentation.", - ), - ), - ), - migrations.RunPython( - convert_from_format_to_template, - migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py b/src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py new file mode 100644 index 000000000..47db2fd91 --- /dev/null +++ b/src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.6 on 2025-09-11 17:29 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1068_alter_document_created"), + ] + + operations = [ + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_storage_path", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="documents.storagepath", + verbose_name="has this storage path", + ), + ), + migrations.AlterField( + model_name="workflowaction", + name="assign_title", + field=models.TextField( + blank=True, + help_text="Assign a document title, must be a Jinja2 template, see documentation.", + null=True, + verbose_name="assign title", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index fc7dd3fdf..0404065cb 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1044,6 +1044,14 @@ class WorkflowTrigger(models.Model): verbose_name=_("has this correspondent"), ) + filter_has_storage_path = models.ForeignKey( + StoragePath, + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name=_("has this storage path"), + ) + schedule_offset_days = models.IntegerField( _("schedule offset days"), default=0, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 33a703f96..c71a856d7 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2054,6 +2054,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): "filter_has_tags", "filter_has_correspondent", "filter_has_document_type", + "filter_has_storage_path", "schedule_offset_days", "schedule_is_recurring", "schedule_recurring_interval_days", diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index 63dca0423..305467048 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -186,6 +186,7 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_tags": [self.t1.id], "filter_has_document_type": self.dt.id, "filter_has_correspondent": self.c.id, + "filter_has_storage_path": self.sp.id, }, ], "actions": [ diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 8c5e8ec9d..fe5c4ff7d 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -1150,6 +1150,38 @@ class TestWorkflows( expected_str = f"Document correspondent {doc.correspondent} does not match {trigger.filter_has_correspondent}" self.assertIn(expected_str, cm.output[1]) + def test_document_added_no_match_storage_path(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_has_storage_path=self.sp, + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + original_filename="sample.pdf", + ) + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + 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_document_added_invalid_title_placeholders(self): """ GIVEN: @@ -1816,6 +1848,7 @@ class TestWorkflows( filter_filename="*sample*", filter_has_document_type=self.dt, filter_has_correspondent=self.c, + filter_has_storage_path=self.sp, ) trigger.filter_has_tags.set([self.t1]) trigger.save() @@ -1836,6 +1869,7 @@ class TestWorkflows( title=f"sample test {i}", checksum=f"checksum{i}", correspondent=self.c, + storage_path=self.sp, original_filename=f"sample_{i}.pdf", document_type=self.dt if i % 2 == 0 else None, )