mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -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 | ||||
|    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 | ||||
|  | ||||
|   | ||||
| @@ -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-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> | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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("$") | ||||
|   | ||||
| @@ -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"), | ||||
|     ) | ||||
|  | ||||
|     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, | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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": [ | ||||
|   | ||||
| @@ -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, | ||||
|             ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 david-loe
					david-loe