mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Enhancement: add storage path as workflow trigger filter (#10771)
--------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
		@@ -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
 | 
					   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.
 | 
					   be used for filtering.
 | 
				
			||||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
 | 
					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
 | 
					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
 | 
					   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).
 | 
					   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
 | 
					-   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.
 | 
					    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.
 | 
					-   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.
 | 
					-   Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
 | 
				
			||||||
-   Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
 | 
					-   Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
 | 
				
			||||||
-   Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
 | 
					-   Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
 | 
				
			||||||
-   Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
 | 
					-   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
 | 
					### Workflow Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -177,6 +177,7 @@
 | 
				
			|||||||
          <pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
 | 
					          <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 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 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>
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -412,6 +412,9 @@ export class WorkflowEditDialogComponent
 | 
				
			|||||||
        filter_has_document_type: new FormControl(
 | 
					        filter_has_document_type: new FormControl(
 | 
				
			||||||
          trigger.filter_has_document_type
 | 
					          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_offset_days: new FormControl(trigger.schedule_offset_days),
 | 
				
			||||||
        schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
 | 
					        schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
 | 
				
			||||||
        schedule_recurring_interval_days: new FormControl(
 | 
					        schedule_recurring_interval_days: new FormControl(
 | 
				
			||||||
@@ -536,6 +539,7 @@ export class WorkflowEditDialogComponent
 | 
				
			|||||||
      filter_has_tags: [],
 | 
					      filter_has_tags: [],
 | 
				
			||||||
      filter_has_correspondent: null,
 | 
					      filter_has_correspondent: null,
 | 
				
			||||||
      filter_has_document_type: null,
 | 
					      filter_has_document_type: null,
 | 
				
			||||||
 | 
					      filter_has_storage_path: null,
 | 
				
			||||||
      matching_algorithm: MATCH_NONE,
 | 
					      matching_algorithm: MATCH_NONE,
 | 
				
			||||||
      match: '',
 | 
					      match: '',
 | 
				
			||||||
      is_insensitive: true,
 | 
					      is_insensitive: true,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -44,6 +44,8 @@ export interface WorkflowTrigger extends ObjectWithId {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  filter_has_document_type?: number // DocumentType.id
 | 
					  filter_has_document_type?: number // DocumentType.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  filter_has_storage_path?: number // StoragePath.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  schedule_offset_days?: number
 | 
					  schedule_offset_days?: number
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  schedule_is_recurring?: boolean
 | 
					  schedule_is_recurring?: boolean
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -386,6 +386,16 @@ def existing_document_matches_workflow(
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        trigger_matched = False
 | 
					        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
 | 
					    # Document original_filename vs trigger filename
 | 
				
			||||||
    if (
 | 
					    if (
 | 
				
			||||||
        trigger.filter_filename is not None
 | 
					        trigger.filter_filename is not None
 | 
				
			||||||
@@ -430,6 +440,11 @@ def prefilter_documents_by_workflowtrigger(
 | 
				
			|||||||
            document_type=trigger.filter_has_document_type,
 | 
					            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:
 | 
					    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
 | 
					        # the true fnmatch will actually run later so we just want a loose filter here
 | 
				
			||||||
        regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
 | 
					        regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -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",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -1044,6 +1044,14 @@ class WorkflowTrigger(models.Model):
 | 
				
			|||||||
        verbose_name=_("has this correspondent"),
 | 
					        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 = models.IntegerField(
 | 
				
			||||||
        _("schedule offset days"),
 | 
					        _("schedule offset days"),
 | 
				
			||||||
        default=0,
 | 
					        default=0,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2054,6 +2054,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
 | 
				
			|||||||
            "filter_has_tags",
 | 
					            "filter_has_tags",
 | 
				
			||||||
            "filter_has_correspondent",
 | 
					            "filter_has_correspondent",
 | 
				
			||||||
            "filter_has_document_type",
 | 
					            "filter_has_document_type",
 | 
				
			||||||
 | 
					            "filter_has_storage_path",
 | 
				
			||||||
            "schedule_offset_days",
 | 
					            "schedule_offset_days",
 | 
				
			||||||
            "schedule_is_recurring",
 | 
					            "schedule_is_recurring",
 | 
				
			||||||
            "schedule_recurring_interval_days",
 | 
					            "schedule_recurring_interval_days",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -186,6 +186,7 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
 | 
				
			|||||||
                            "filter_has_tags": [self.t1.id],
 | 
					                            "filter_has_tags": [self.t1.id],
 | 
				
			||||||
                            "filter_has_document_type": self.dt.id,
 | 
					                            "filter_has_document_type": self.dt.id,
 | 
				
			||||||
                            "filter_has_correspondent": self.c.id,
 | 
					                            "filter_has_correspondent": self.c.id,
 | 
				
			||||||
 | 
					                            "filter_has_storage_path": self.sp.id,
 | 
				
			||||||
                        },
 | 
					                        },
 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                    "actions": [
 | 
					                    "actions": [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1150,6 +1150,38 @@ class TestWorkflows(
 | 
				
			|||||||
            expected_str = f"Document correspondent {doc.correspondent} does not match {trigger.filter_has_correspondent}"
 | 
					            expected_str = f"Document correspondent {doc.correspondent} does not match {trigger.filter_has_correspondent}"
 | 
				
			||||||
            self.assertIn(expected_str, cm.output[1])
 | 
					            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):
 | 
					    def test_document_added_invalid_title_placeholders(self):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        GIVEN:
 | 
					        GIVEN:
 | 
				
			||||||
@@ -1816,6 +1848,7 @@ class TestWorkflows(
 | 
				
			|||||||
            filter_filename="*sample*",
 | 
					            filter_filename="*sample*",
 | 
				
			||||||
            filter_has_document_type=self.dt,
 | 
					            filter_has_document_type=self.dt,
 | 
				
			||||||
            filter_has_correspondent=self.c,
 | 
					            filter_has_correspondent=self.c,
 | 
				
			||||||
 | 
					            filter_has_storage_path=self.sp,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        trigger.filter_has_tags.set([self.t1])
 | 
					        trigger.filter_has_tags.set([self.t1])
 | 
				
			||||||
        trigger.save()
 | 
					        trigger.save()
 | 
				
			||||||
@@ -1836,6 +1869,7 @@ class TestWorkflows(
 | 
				
			|||||||
                title=f"sample test {i}",
 | 
					                title=f"sample test {i}",
 | 
				
			||||||
                checksum=f"checksum{i}",
 | 
					                checksum=f"checksum{i}",
 | 
				
			||||||
                correspondent=self.c,
 | 
					                correspondent=self.c,
 | 
				
			||||||
 | 
					                storage_path=self.sp,
 | 
				
			||||||
                original_filename=f"sample_{i}.pdf",
 | 
					                original_filename=f"sample_{i}.pdf",
 | 
				
			||||||
                document_type=self.dt if i % 2 == 0 else None,
 | 
					                document_type=self.dt if i % 2 == 0 else None,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user