Enhancement: add storage path as workflow trigger filter (#10771)

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
david-loe
2025-09-11 19:41:04 +02:00
committed by GitHub
parent 17509171bb
commit 2dc4f1f49b
11 changed files with 107 additions and 56 deletions

View 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 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

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -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("$")

View File

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

View File

@@ -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",
),
),
]

View File

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

View File

@@ -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",

View File

@@ -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": [

View File

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