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,
)