mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-14 21:45:37 -05: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