mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Enhancement: support negative offset in scheduled workflows (#9746)
This commit is contained in:
		| @@ -0,0 +1,22 @@ | ||||
| # Generated by Django 5.1.7 on 2025-04-15 19:18 | ||||
|  | ||||
| from django.db import migrations | ||||
| from django.db import models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("documents", "1065_workflowaction_assign_custom_fields_values"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="workflowtrigger", | ||||
|             name="schedule_offset_days", | ||||
|             field=models.IntegerField( | ||||
|                 default=0, | ||||
|                 help_text="The number of days to offset the schedule trigger by.", | ||||
|                 verbose_name="schedule offset days", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1019,7 +1019,7 @@ class WorkflowTrigger(models.Model): | ||||
|         verbose_name=_("has this correspondent"), | ||||
|     ) | ||||
|  | ||||
|     schedule_offset_days = models.PositiveIntegerField( | ||||
|     schedule_offset_days = models.IntegerField( | ||||
|         _("schedule offset days"), | ||||
|         default=0, | ||||
|         help_text=_( | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import datetime | ||||
| import hashlib | ||||
| import logging | ||||
| import shutil | ||||
| import uuid | ||||
| from datetime import timedelta | ||||
| from pathlib import Path | ||||
| from tempfile import TemporaryDirectory | ||||
|  | ||||
| @@ -357,7 +357,7 @@ def empty_trash(doc_ids=None): | ||||
|         if doc_ids is not None | ||||
|         else Document.deleted_objects.filter( | ||||
|             deleted_at__lt=timezone.localtime(timezone.now()) | ||||
|             - timedelta( | ||||
|             - datetime.timedelta( | ||||
|                 days=settings.EMPTY_TRASH_DELAY, | ||||
|             ), | ||||
|         ) | ||||
| @@ -397,6 +397,7 @@ def check_scheduled_workflows(): | ||||
|     ) | ||||
|     if scheduled_workflows.count() > 0: | ||||
|         logger.debug(f"Checking {len(scheduled_workflows)} scheduled workflows") | ||||
|         now = timezone.now() | ||||
|         for workflow in scheduled_workflows: | ||||
|             schedule_triggers = workflow.triggers.filter( | ||||
|                 type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED, | ||||
| @@ -404,31 +405,60 @@ def check_scheduled_workflows(): | ||||
|             trigger: WorkflowTrigger | ||||
|             for trigger in schedule_triggers: | ||||
|                 documents = Document.objects.none() | ||||
|                 offset_td = timedelta(days=trigger.schedule_offset_days) | ||||
|                 offset_td = datetime.timedelta(days=-trigger.schedule_offset_days) | ||||
|                 threshold = now - offset_td | ||||
|                 logger.debug( | ||||
|                     f"Checking trigger {trigger} with offset {offset_td} against field: {trigger.schedule_date_field}", | ||||
|                     f"Trigger {trigger.id}: checking if (date + {offset_td}) <= now ({now})", | ||||
|                 ) | ||||
|  | ||||
|                 match trigger.schedule_date_field: | ||||
|                     case WorkflowTrigger.ScheduleDateField.ADDED: | ||||
|                         documents = Document.objects.filter( | ||||
|                             added__lt=timezone.now() - offset_td, | ||||
|                         ) | ||||
|                         documents = Document.objects.filter(added__lte=threshold) | ||||
|  | ||||
|                     case WorkflowTrigger.ScheduleDateField.CREATED: | ||||
|                         documents = Document.objects.filter( | ||||
|                             created__lt=timezone.now() - offset_td, | ||||
|                         ) | ||||
|                         documents = Document.objects.filter(created__lte=threshold) | ||||
|  | ||||
|                     case WorkflowTrigger.ScheduleDateField.MODIFIED: | ||||
|                         documents = Document.objects.filter( | ||||
|                             modified__lt=timezone.now() - offset_td, | ||||
|                         ) | ||||
|                         documents = Document.objects.filter(modified__lte=threshold) | ||||
|  | ||||
|                     case WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD: | ||||
|                         cf_instances = CustomFieldInstance.objects.filter( | ||||
|                             field=trigger.schedule_date_custom_field, | ||||
|                             value_date__lt=timezone.now() - offset_td, | ||||
|                         ) | ||||
|                         documents = Document.objects.filter( | ||||
|                             id__in=cf_instances.values_list("document", flat=True), | ||||
|                         # cap earliest date to avoid massive scans | ||||
|                         earliest_date = now - datetime.timedelta(days=365) | ||||
|                         if offset_td.days < -365: | ||||
|                             logger.warning( | ||||
|                                 f"Trigger {trigger.id} has large negative offset ({offset_td.days}), " | ||||
|                                 f"limiting earliest scan date to {earliest_date}", | ||||
|                             ) | ||||
|  | ||||
|                         cf_filter_kwargs = { | ||||
|                             "field": trigger.schedule_date_custom_field, | ||||
|                             "value_date__isnull": False, | ||||
|                             "value_date__lte": threshold, | ||||
|                             "value_date__gte": earliest_date, | ||||
|                         } | ||||
|  | ||||
|                         recent_cf_instances = CustomFieldInstance.objects.filter( | ||||
|                             **cf_filter_kwargs, | ||||
|                         ) | ||||
|  | ||||
|                         matched_ids = [ | ||||
|                             cfi.document_id | ||||
|                             for cfi in recent_cf_instances | ||||
|                             if cfi.value_date | ||||
|                             and ( | ||||
|                                 timezone.make_aware( | ||||
|                                     datetime.datetime.combine( | ||||
|                                         cfi.value_date, | ||||
|                                         datetime.time.min, | ||||
|                                     ), | ||||
|                                 ) | ||||
|                                 + offset_td | ||||
|                                 <= now | ||||
|                             ) | ||||
|                         ] | ||||
|  | ||||
|                         documents = Document.objects.filter(id__in=matched_ids) | ||||
|  | ||||
|                 if documents.count() > 0: | ||||
|                     logger.debug( | ||||
|                         f"Found {documents.count()} documents for trigger {trigger}", | ||||
| @@ -440,18 +470,18 @@ def check_scheduled_workflows(): | ||||
|                             workflow=workflow, | ||||
|                         ).order_by("-run_at") | ||||
|                         if not trigger.schedule_is_recurring and workflow_runs.exists(): | ||||
|                             # schedule is non-recurring and the workflow has already been run | ||||
|                             logger.debug( | ||||
|                                 f"Skipping document {document} for non-recurring workflow {workflow} as it has already been run", | ||||
|                             ) | ||||
|                             continue | ||||
|                         elif ( | ||||
|  | ||||
|                         if ( | ||||
|                             trigger.schedule_is_recurring | ||||
|                             and workflow_runs.exists() | ||||
|                             and ( | ||||
|                                 workflow_runs.last().run_at | ||||
|                                 > timezone.now() | ||||
|                                 - timedelta( | ||||
|                                 > now | ||||
|                                 - datetime.timedelta( | ||||
|                                     days=trigger.schedule_recurring_interval_days, | ||||
|                                 ) | ||||
|                             ) | ||||
|   | ||||
| @@ -1336,6 +1336,8 @@ class TestWorkflows( | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger against the created field and action that assigns owner | ||||
|             - Existing doc that matches the trigger | ||||
|             - Workflow set to trigger at (now - offset) = now - 1 day | ||||
|             - Document created date is 2 days ago → trigger condition met | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked | ||||
|         THEN: | ||||
| @@ -1359,7 +1361,7 @@ class TestWorkflows( | ||||
|         w.save() | ||||
|  | ||||
|         now = timezone.localtime(timezone.now()) | ||||
|         created = now - timedelta(weeks=520) | ||||
|         created = now - timedelta(days=2) | ||||
|         doc = Document.objects.create( | ||||
|             title="sample test", | ||||
|             correspondent=self.c, | ||||
| @@ -1377,6 +1379,8 @@ class TestWorkflows( | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger against the added field and action that assigns owner | ||||
|             - Existing doc that matches the trigger | ||||
|             - Workflow set to trigger at (now - offset) = now - 1 day | ||||
|             - Document added date is 365 days ago | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked | ||||
|         THEN: | ||||
| @@ -1418,6 +1422,8 @@ class TestWorkflows( | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger against the modified field and action that assigns owner | ||||
|             - Existing doc that matches the trigger | ||||
|             - Workflow set to trigger at (now - offset) = now - 1 day | ||||
|             - Document modified date is mocked as sufficiently in the past | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked | ||||
|         THEN: | ||||
| @@ -1458,6 +1464,8 @@ class TestWorkflows( | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger against a custom field and action that assigns owner | ||||
|             - Existing doc that matches the trigger | ||||
|             - Workflow set to trigger at (now - offset) = now - 1 day | ||||
|             - Custom field date is 2 days ago | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked | ||||
|         THEN: | ||||
| @@ -1502,6 +1510,7 @@ class TestWorkflows( | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger | ||||
|             - Existing doc that has already had the workflow run | ||||
|             - Document created 2 days ago, workflow offset = 1 day → trigger time = yesterday | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked | ||||
|         THEN: | ||||
| @@ -1552,6 +1561,7 @@ class TestWorkflows( | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger and recurring interval of 7 days | ||||
|             - Workflow run date is 6 days ago | ||||
|             - Document created 40 days ago, offset = 30 → trigger time = 10 days ago | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked | ||||
|         THEN: | ||||
| @@ -1600,6 +1610,58 @@ class TestWorkflows( | ||||
|             doc.refresh_from_db() | ||||
|             self.assertIsNone(doc.owner) | ||||
|  | ||||
|     def test_workflow_scheduled_trigger_negative_offset(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing workflow with SCHEDULED trigger and negative offset of -7 days (so 7 days after date) | ||||
|             - Custom field date initially set to 5 days ago → trigger time = 2 days in future | ||||
|             - Then updated to 8 days ago → trigger time = 1 day ago | ||||
|         WHEN: | ||||
|             - Scheduled workflows are checked for document with custom field date 8 days in the past | ||||
|         THEN: | ||||
|             - Workflow runs and document owner is updated | ||||
|         """ | ||||
|         trigger = WorkflowTrigger.objects.create( | ||||
|             type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED, | ||||
|             schedule_offset_days=-7, | ||||
|             schedule_date_field=WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD, | ||||
|             schedule_date_custom_field=self.cf1, | ||||
|         ) | ||||
|         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", | ||||
|             correspondent=self.c, | ||||
|             original_filename="sample.pdf", | ||||
|         ) | ||||
|         cfi = CustomFieldInstance.objects.create( | ||||
|             document=doc, | ||||
|             field=self.cf1, | ||||
|             value_date=timezone.now() - timedelta(days=5), | ||||
|         ) | ||||
|  | ||||
|         tasks.check_scheduled_workflows() | ||||
|  | ||||
|         doc.refresh_from_db() | ||||
|         self.assertIsNone(doc.owner)  # has not triggered yet | ||||
|  | ||||
|         cfi.value_date = timezone.now() - timedelta(days=8) | ||||
|         cfi.save() | ||||
|  | ||||
|         tasks.check_scheduled_workflows() | ||||
|         doc.refresh_from_db() | ||||
|         self.assertEqual(doc.owner, self.user2) | ||||
|  | ||||
|     def test_workflow_enabled_disabled(self): | ||||
|         trigger = WorkflowTrigger.objects.create( | ||||
|             type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon