]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: add storage path as workflow trigger filter (#10771)
authordavid-loe <56305409+david-loe@users.noreply.github.com>
Thu, 11 Sep 2025 17:41:04 +0000 (19:41 +0200)
committerGitHub <noreply@github.com>
Thu, 11 Sep 2025 17:41:04 +0000 (17:41 +0000)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
docs/usage.md
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
src-ui/src/app/data/workflow-trigger.ts
src/documents/matching.py
src/documents/migrations/1069_migrate_workflow_title_jinja.py [deleted file]
src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/tests/test_api_workflows.py
src/documents/tests/test_workflows.py

index 864eab0c10f20c4cd1f89097f4ff930d8eb51e80..d0c749f8dc1ff740256fd0820e951bbb5e2fda9d 100644 (file)
@@ -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
 
index 2155979d6266d9556d0521ea0f172b0f08e82d1f..7163ba289ead5f346072dd72588684ff3f061a9e 100644 (file)
           <pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
           <pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
           <pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
+          <pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
         </div>
       }
     </div>
index 015b4011394f587a2194a4c391357740e3876c0e..ec27d6c59ebe4a324deba1cd84f17c081127f113 100644 (file)
@@ -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,
index 4299356b0677c5982e6c1b53e129d9a64f6a3211..6e2d9cda7e25fb4ae098c9fc9c590bc6494a4e90 100644 (file)
@@ -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
index 346f9d55a3e480d23aff221aa87edaa4b637d793..2088a60423f976fb34544e334d6a12822ac6b4cb 100644 (file)
@@ -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 (file)
index 52b7019..0000000
+++ /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 (file)
index 0000000..47db2fd
--- /dev/null
@@ -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",
+            ),
+        ),
+    ]
index fc7dd3fdff097deac6b505c92f39696291789243..0404065cb0f4bd82758720d711ce941a28374b0c 100644 (file)
@@ -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,
index 33a703f96b8df576f4c1797b17a04f18a867cbff..c71a856d7c10aa62229d6ad1cca880aa1257bbbc 100644 (file)
@@ -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",
index 63dca042397226dc5f4933f0d6cf8f8f65b7fd46..305467048ab55fa68493e8c7a1cfe66a4120a1a2 100644 (file)
@@ -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": [
index 8c5e8ec9dcf5327cbef1a8a7a60644c72041c4ab..fe5c4ff7d73e2884a0faa8efc7f5fe3c0e6a9232 100644 (file)
@@ -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,
             )