- @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
+ @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
index 39925f2f9..28a0e8bc0 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
@@ -22,6 +22,7 @@ import { SwitchComponent } from '../../input/switch/switch.component'
import { EditDialogMode } from '../edit-dialog.component'
import {
DOCUMENT_SOURCE_OPTIONS,
+ SCHEDULE_DATE_FIELD_OPTIONS,
WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent,
@@ -40,6 +41,7 @@ import {
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { CustomFieldDataType } from 'src/app/data/custom-field'
const workflow: Workflow = {
name: 'Workflow 1',
@@ -148,7 +150,18 @@ describe('WorkflowEditDialogComponent', () => {
useValue: {
listAll: () =>
of({
- results: [],
+ results: [
+ {
+ id: 1,
+ name: 'cf1',
+ data_type: CustomFieldDataType.String,
+ },
+ {
+ id: 2,
+ name: 'cf2',
+ data_type: CustomFieldDataType.Date,
+ },
+ ],
}),
},
},
@@ -186,7 +199,7 @@ describe('WorkflowEditDialogComponent', () => {
expect(editTitleSpy).toHaveBeenCalled()
})
- it('should return source options, type options, type name', () => {
+ it('should return source options, type options, type name, schedule date field options', () => {
// coverage
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
@@ -200,6 +213,9 @@ describe('WorkflowEditDialogComponent', () => {
component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment')
expect(component.getActionTypeOptionName(null)).toEqual('')
+ expect(component.scheduleDateFieldOptions).toEqual(
+ SCHEDULE_DATE_FIELD_OPTIONS
+ )
})
it('should support add and remove triggers and actions', () => {
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
index 588202b89..646085105 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
@@ -16,9 +16,10 @@ import { EditDialogComponent } from '../edit-dialog.component'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { MailRule } from 'src/app/data/mail-rule'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
-import { CustomField } from 'src/app/data/custom-field'
+import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
DocumentSource,
+ ScheduleDateField,
WorkflowTrigger,
WorkflowTriggerType,
} from 'src/app/data/workflow-trigger'
@@ -48,6 +49,25 @@ export const DOCUMENT_SOURCE_OPTIONS = [
},
]
+export const SCHEDULE_DATE_FIELD_OPTIONS = [
+ {
+ id: ScheduleDateField.Added,
+ name: $localize`Added`,
+ },
+ {
+ id: ScheduleDateField.Created,
+ name: $localize`Created`,
+ },
+ {
+ id: ScheduleDateField.Modified,
+ name: $localize`Modified`,
+ },
+ {
+ id: ScheduleDateField.CustomField,
+ name: $localize`Custom Field`,
+ },
+]
+
export const WORKFLOW_TYPE_OPTIONS = [
{
id: WorkflowTriggerType.Consumption,
@@ -61,6 +81,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
id: WorkflowTriggerType.DocumentUpdated,
name: $localize`Document Updated`,
},
+ {
+ id: WorkflowTriggerType.Scheduled,
+ name: $localize`Scheduled`,
+ },
]
export const WORKFLOW_ACTION_OPTIONS = [
@@ -96,6 +120,7 @@ export class WorkflowEditDialogComponent
storagePaths: StoragePath[]
mailRules: MailRule[]
customFields: CustomField[]
+ dateCustomFields: CustomField[]
expandedItem: number = null
@@ -135,7 +160,12 @@ export class WorkflowEditDialogComponent
customFieldsService
.listAll()
.pipe(first())
- .subscribe((result) => (this.customFields = result.results))
+ .subscribe((result) => {
+ this.customFields = result.results
+ this.dateCustomFields = this.customFields?.filter(
+ (f) => f.data_type === CustomFieldDataType.Date
+ )
+ })
}
getCreateTitle() {
@@ -314,6 +344,15 @@ export class WorkflowEditDialogComponent
filter_has_document_type: new FormControl(
trigger.filter_has_document_type
),
+ schedule_offset_days: new FormControl(trigger.schedule_offset_days),
+ schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
+ schedule_recurring_interval_days: new FormControl(
+ trigger.schedule_recurring_interval_days
+ ),
+ schedule_date_field: new FormControl(trigger.schedule_date_field),
+ schedule_date_custom_field: new FormControl(
+ trigger.schedule_date_custom_field
+ ),
}),
{ emitEvent }
)
@@ -388,6 +427,10 @@ export class WorkflowEditDialogComponent
return WORKFLOW_TYPE_OPTIONS
}
+ get scheduleDateFieldOptions() {
+ return SCHEDULE_DATE_FIELD_OPTIONS
+ }
+
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
}
@@ -408,6 +451,11 @@ export class WorkflowEditDialogComponent
matching_algorithm: MATCH_NONE,
match: '',
is_insensitive: true,
+ schedule_offset_days: 0,
+ schedule_is_recurring: false,
+ schedule_recurring_interval_days: 1,
+ schedule_date_field: ScheduleDateField.Added,
+ schedule_date_custom_field: null,
}
this.object.triggers.push(trigger)
this.createTriggerField(trigger)
diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts
index 3e3bf8cf8..12f76b7a3 100644
--- a/src-ui/src/app/data/workflow-trigger.ts
+++ b/src-ui/src/app/data/workflow-trigger.ts
@@ -10,6 +10,14 @@ export enum WorkflowTriggerType {
Consumption = 1,
DocumentAdded = 2,
DocumentUpdated = 3,
+ Scheduled = 4,
+}
+
+export enum ScheduleDateField {
+ Added = 'added',
+ Created = 'created',
+ Modified = 'modified',
+ CustomField = 'custom_field',
}
export interface WorkflowTrigger extends ObjectWithId {
@@ -34,4 +42,14 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id
+
+ schedule_offset_days?: number
+
+ schedule_is_recurring?: boolean
+
+ schedule_recurring_interval_days?: number
+
+ schedule_date_field?: ScheduleDateField
+
+ schedule_date_custom_field?: number // CustomField.id
}
diff --git a/src/documents/matching.py b/src/documents/matching.py
index 36fa9a2c6..59c0ccfda 100644
--- a/src/documents/matching.py
+++ b/src/documents/matching.py
@@ -409,6 +409,7 @@ def document_matches_workflow(
elif (
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
+ or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
):
trigger_matched, reason = existing_document_matches_workflow(
document,
diff --git a/src/documents/migrations/1058_workflowtrigger_schedule_date_custom_field_and_more.py b/src/documents/migrations/1058_workflowtrigger_schedule_date_custom_field_and_more.py
new file mode 100644
index 000000000..05d38578a
--- /dev/null
+++ b/src/documents/migrations/1058_workflowtrigger_schedule_date_custom_field_and_more.py
@@ -0,0 +1,143 @@
+# Generated by Django 5.1.1 on 2024-11-05 05:19
+
+import django.core.validators
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1057_paperlesstask_owner"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="workflowtrigger",
+ name="schedule_date_custom_field",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="documents.customfield",
+ verbose_name="schedule date custom field",
+ ),
+ ),
+ migrations.AddField(
+ model_name="workflowtrigger",
+ name="schedule_date_field",
+ field=models.CharField(
+ choices=[
+ ("added", "Added"),
+ ("created", "Created"),
+ ("modified", "Modified"),
+ ("custom_field", "Custom Field"),
+ ],
+ default="added",
+ help_text="The field to check for a schedule trigger.",
+ max_length=20,
+ verbose_name="schedule date field",
+ ),
+ ),
+ migrations.AddField(
+ model_name="workflowtrigger",
+ name="schedule_is_recurring",
+ field=models.BooleanField(
+ default=False,
+ help_text="If the schedule should be recurring.",
+ verbose_name="schedule is recurring",
+ ),
+ ),
+ migrations.AddField(
+ model_name="workflowtrigger",
+ name="schedule_offset_days",
+ field=models.PositiveIntegerField(
+ default=0,
+ help_text="The number of days to offset the schedule trigger by.",
+ verbose_name="schedule offset days",
+ ),
+ ),
+ migrations.AddField(
+ model_name="workflowtrigger",
+ name="schedule_recurring_interval_days",
+ field=models.PositiveIntegerField(
+ default=1,
+ help_text="The number of days between recurring schedule triggers.",
+ validators=[django.core.validators.MinValueValidator(1)],
+ verbose_name="schedule recurring delay in days",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="workflowtrigger",
+ name="type",
+ field=models.PositiveIntegerField(
+ choices=[
+ (1, "Consumption Started"),
+ (2, "Document Added"),
+ (3, "Document Updated"),
+ (4, "Scheduled"),
+ ],
+ default=1,
+ verbose_name="Workflow Trigger Type",
+ ),
+ ),
+ migrations.CreateModel(
+ name="WorkflowRun",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "type",
+ models.PositiveIntegerField(
+ choices=[
+ (1, "Consumption Started"),
+ (2, "Document Added"),
+ (3, "Document Updated"),
+ (4, "Scheduled"),
+ ],
+ null=True,
+ verbose_name="workflow trigger type",
+ ),
+ ),
+ (
+ "run_at",
+ models.DateTimeField(
+ db_index=True,
+ default=django.utils.timezone.now,
+ verbose_name="date run",
+ ),
+ ),
+ (
+ "document",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workflow_runs",
+ to="documents.document",
+ verbose_name="document",
+ ),
+ ),
+ (
+ "workflow",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="runs",
+ to="documents.workflow",
+ verbose_name="workflow",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "workflow run",
+ "verbose_name_plural": "workflow runs",
+ },
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 05226b0e9..6ba63a7e4 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1016,12 +1016,19 @@ class WorkflowTrigger(models.Model):
CONSUMPTION = 1, _("Consumption Started")
DOCUMENT_ADDED = 2, _("Document Added")
DOCUMENT_UPDATED = 3, _("Document Updated")
+ SCHEDULED = 4, _("Scheduled")
class DocumentSourceChoices(models.IntegerChoices):
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
+ class ScheduleDateField(models.TextChoices):
+ ADDED = "added", _("Added")
+ CREATED = "created", _("Created")
+ MODIFIED = "modified", _("Modified")
+ CUSTOM_FIELD = "custom_field", _("Custom Field")
+
type = models.PositiveIntegerField(
_("Workflow Trigger Type"),
choices=WorkflowTriggerType.choices,
@@ -1098,6 +1105,49 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this correspondent"),
)
+ schedule_offset_days = models.PositiveIntegerField(
+ _("schedule offset days"),
+ default=0,
+ help_text=_(
+ "The number of days to offset the schedule trigger by.",
+ ),
+ )
+
+ schedule_is_recurring = models.BooleanField(
+ _("schedule is recurring"),
+ default=False,
+ help_text=_(
+ "If the schedule should be recurring.",
+ ),
+ )
+
+ schedule_recurring_interval_days = models.PositiveIntegerField(
+ _("schedule recurring delay in days"),
+ default=1,
+ validators=[MinValueValidator(1)],
+ help_text=_(
+ "The number of days between recurring schedule triggers.",
+ ),
+ )
+
+ schedule_date_field = models.CharField(
+ _("schedule date field"),
+ max_length=20,
+ choices=ScheduleDateField.choices,
+ default=ScheduleDateField.ADDED,
+ help_text=_(
+ "The field to check for a schedule trigger.",
+ ),
+ )
+
+ schedule_date_custom_field = models.ForeignKey(
+ CustomField,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ verbose_name=_("schedule date custom field"),
+ )
+
class Meta:
verbose_name = _("workflow trigger")
verbose_name_plural = _("workflow triggers")
@@ -1348,3 +1398,39 @@ class Workflow(models.Model):
def __str__(self):
return f"Workflow: {self.name}"
+
+
+class WorkflowRun(models.Model):
+ workflow = models.ForeignKey(
+ Workflow,
+ on_delete=models.CASCADE,
+ related_name="runs",
+ verbose_name=_("workflow"),
+ )
+
+ type = models.PositiveIntegerField(
+ _("workflow trigger type"),
+ choices=WorkflowTrigger.WorkflowTriggerType.choices,
+ null=True,
+ )
+
+ document = models.ForeignKey(
+ Document,
+ null=True,
+ on_delete=models.CASCADE,
+ related_name="workflow_runs",
+ verbose_name=_("document"),
+ )
+
+ run_at = models.DateTimeField(
+ _("date run"),
+ default=timezone.now,
+ db_index=True,
+ )
+
+ class Meta:
+ verbose_name = _("workflow run")
+ verbose_name_plural = _("workflow runs")
+
+ def __str__(self):
+ return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index f960cac24..8c7973f96 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1772,6 +1772,11 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"filter_has_tags",
"filter_has_correspondent",
"filter_has_document_type",
+ "schedule_offset_days",
+ "schedule_is_recurring",
+ "schedule_recurring_interval_days",
+ "schedule_date_field",
+ "schedule_date_custom_field",
]
def validate(self, attrs):
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index cd2e3972e..c6d6c4090 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -37,6 +37,7 @@ from documents.models import PaperlessTask
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
+from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object
@@ -916,6 +917,12 @@ def run_workflows(
document.save()
document.tags.set(doc_tag_ids)
+ WorkflowRun.objects.create(
+ workflow=workflow,
+ type=trigger_type,
+ document=document if not use_overrides else None,
+ )
+
if use_overrides:
return overrides, "\n".join(messages)
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index e04cdb34e..56c2d92f1 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -31,10 +31,14 @@ from documents.double_sided import CollatePlugin
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.models import Correspondent
+from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
+from documents.models import Workflow
+from documents.models import WorkflowRun
+from documents.models import WorkflowTrigger
from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type
from documents.plugins.base import ConsumeTaskPlugin
@@ -44,6 +48,7 @@ from documents.plugins.helpers import ProgressStatusOptions
from documents.sanity_checker import SanityCheckFailedException
from documents.signals import document_updated
from documents.signals.handlers import cleanup_document_deletion
+from documents.signals.handlers import run_workflows
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
@@ -337,3 +342,81 @@ def empty_trash(doc_ids=None):
cleanup_document_deletion,
sender=Document,
)
+
+
+@shared_task
+def check_scheduled_workflows():
+ scheduled_workflows: list[Workflow] = Workflow.objects.filter(
+ triggers__type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ enabled=True,
+ ).prefetch_related("triggers")
+ if scheduled_workflows.count() > 0:
+ logger.debug(f"Checking {len(scheduled_workflows)} scheduled workflows")
+ for workflow in scheduled_workflows:
+ schedule_triggers = workflow.triggers.filter(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ )
+ trigger: WorkflowTrigger
+ for trigger in schedule_triggers:
+ documents = Document.objects.none()
+ offset_td = timedelta(days=trigger.schedule_offset_days)
+ logger.debug(
+ f"Checking trigger {trigger} with offset {offset_td} against field: {trigger.schedule_date_field}",
+ )
+ match trigger.schedule_date_field:
+ case WorkflowTrigger.ScheduleDateField.ADDED:
+ documents = Document.objects.filter(
+ added__lt=timezone.now() - offset_td,
+ )
+ case WorkflowTrigger.ScheduleDateField.CREATED:
+ documents = Document.objects.filter(
+ created__lt=timezone.now() - offset_td,
+ )
+ case WorkflowTrigger.ScheduleDateField.MODIFIED:
+ documents = Document.objects.filter(
+ modified__lt=timezone.now() - offset_td,
+ )
+ 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),
+ )
+ if documents.count() > 0:
+ logger.debug(
+ f"Found {documents.count()} documents for trigger {trigger}",
+ )
+ for document in documents:
+ workflow_runs = WorkflowRun.objects.filter(
+ document=document,
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ 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 (
+ trigger.schedule_is_recurring
+ and workflow_runs.exists()
+ and (
+ workflow_runs.last().run_at
+ > timezone.now()
+ - timedelta(
+ days=trigger.schedule_recurring_interval_days,
+ )
+ )
+ ):
+ # schedule is recurring but the last run was within the number of recurring interval days
+ logger.debug(
+ f"Skipping document {document} for recurring workflow {workflow} as the last run was within the recurring interval",
+ )
+ continue
+ run_workflows(
+ WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ document,
+ )
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index c5d975958..03de5e1c9 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -29,6 +29,7 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
+from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished
from documents.tests.utils import DirectoriesMixin
@@ -1306,6 +1307,275 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
# group2 should have been added
self.assertIn(self.group2, group_perms)
+ def test_workflow_scheduled_trigger_created(self):
+ """
+ GIVEN:
+ - Existing workflow with SCHEDULED trigger against the created field and action that assigns owner
+ - Existing doc that matches the trigger
+ WHEN:
+ - Scheduled workflows are checked
+ THEN:
+ - Workflow runs, document owner is updated
+ """
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=1,
+ schedule_date_field="created",
+ )
+ 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()
+
+ now = timezone.localtime(timezone.now())
+ created = now - timedelta(weeks=520)
+ doc = Document.objects.create(
+ title="sample test",
+ correspondent=self.c,
+ original_filename="sample.pdf",
+ created=created,
+ )
+
+ tasks.check_scheduled_workflows()
+
+ doc.refresh_from_db()
+ self.assertEqual(doc.owner, self.user2)
+
+ def test_workflow_scheduled_trigger_added(self):
+ """
+ GIVEN:
+ - Existing workflow with SCHEDULED trigger against the added field and action that assigns owner
+ - Existing doc that matches the trigger
+ WHEN:
+ - Scheduled workflows are checked
+ THEN:
+ - Workflow runs, document owner is updated
+ """
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=1,
+ schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
+ )
+ 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()
+
+ added = timezone.now() - timedelta(days=365)
+ doc = Document.objects.create(
+ title="sample test",
+ correspondent=self.c,
+ original_filename="sample.pdf",
+ added=added,
+ )
+
+ tasks.check_scheduled_workflows()
+
+ doc.refresh_from_db()
+ self.assertEqual(doc.owner, self.user2)
+
+ @mock.patch("documents.models.Document.objects.filter", autospec=True)
+ def test_workflow_scheduled_trigger_modified(self, mock_filter):
+ """
+ GIVEN:
+ - Existing workflow with SCHEDULED trigger against the modified field and action that assigns owner
+ - Existing doc that matches the trigger
+ WHEN:
+ - Scheduled workflows are checked
+ THEN:
+ - Workflow runs, document owner is updated
+ """
+ # we have to mock because modified field is auto_now
+ mock_filter.return_value = Document.objects.all()
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=1,
+ schedule_date_field=WorkflowTrigger.ScheduleDateField.MODIFIED,
+ )
+ 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",
+ )
+
+ tasks.check_scheduled_workflows()
+
+ doc.refresh_from_db()
+ self.assertEqual(doc.owner, self.user2)
+
+ def test_workflow_scheduled_trigger_custom_field(self):
+ """
+ GIVEN:
+ - Existing workflow with SCHEDULED trigger against a custom field and action that assigns owner
+ - Existing doc that matches the trigger
+ WHEN:
+ - Scheduled workflows are checked
+ THEN:
+ - Workflow runs, document owner is updated
+ """
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=1,
+ 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",
+ )
+ CustomFieldInstance.objects.create(
+ document=doc,
+ field=self.cf1,
+ value_date=timezone.now() - timedelta(days=2),
+ )
+
+ tasks.check_scheduled_workflows()
+
+ doc.refresh_from_db()
+ self.assertEqual(doc.owner, self.user2)
+
+ def test_workflow_scheduled_already_run(self):
+ """
+ GIVEN:
+ - Existing workflow with SCHEDULED trigger
+ - Existing doc that has already had the workflow run
+ WHEN:
+ - Scheduled workflows are checked
+ THEN:
+ - Workflow does not run again
+ """
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=1,
+ schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
+ )
+ 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",
+ created=timezone.now() - timedelta(days=2),
+ )
+
+ wr = WorkflowRun.objects.create(
+ workflow=w,
+ document=doc,
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ run_at=timezone.now(),
+ )
+ self.assertEqual(
+ str(wr),
+ f"WorkflowRun of {w} at {wr.run_at} on {doc}",
+ ) # coverage
+
+ tasks.check_scheduled_workflows()
+
+ doc.refresh_from_db()
+ self.assertIsNone(doc.owner)
+
+ def test_workflow_scheduled_trigger_too_early(self):
+ """
+ GIVEN:
+ - Existing workflow with SCHEDULED trigger and recurring interval of 7 days
+ - Workflow run date is 6 days ago
+ WHEN:
+ - Scheduled workflows are checked
+ THEN:
+ - Workflow does not run as the offset is not met
+ """
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=30,
+ schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
+ schedule_is_recurring=True,
+ schedule_recurring_interval_days=7,
+ )
+ 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",
+ created=timezone.now() - timedelta(days=40),
+ )
+
+ WorkflowRun.objects.create(
+ workflow=w,
+ document=doc,
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ run_at=timezone.now() - timedelta(days=6),
+ )
+
+ with self.assertLogs(level="DEBUG") as cm:
+ tasks.check_scheduled_workflows()
+ self.assertIn(
+ "last run was within the recurring interval",
+ " ".join(cm.output),
+ )
+
+ doc.refresh_from_db()
+ self.assertIsNone(doc.owner)
+
def test_workflow_enabled_disabled(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
@@ -1354,7 +1624,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
def test_new_trigger_type_raises_exception(self):
trigger = WorkflowTrigger.objects.create(
- type=4,
+ type=99,
)
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
@@ -1370,7 +1640,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
doc = Document.objects.create(
title="test",
)
- self.assertRaises(Exception, document_matches_workflow, doc, w, 4)
+ self.assertRaises(Exception, document_matches_workflow, doc, w, 99)
def test_removal_action_document_updated_workflow(self):
"""
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
index 1a495de09..c9462966d 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -216,6 +216,17 @@ def _parse_beat_schedule() -> dict:
"expires": 23.0 * 60.0 * 60.0,
},
},
+ {
+ "name": "Check and run scheduled workflows",
+ "env_key": "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON",
+ # Default hourly at 5 minutes past the hour
+ "env_default": "5 */1 * * *",
+ "task": "documents.tasks.check_scheduled_workflows",
+ "options": {
+ # 1 minute before default schedule sends again
+ "expires": 59.0 * 60.0,
+ },
+ },
]
for task in tasks:
# Either get the environment setting or use the default
diff --git a/src/paperless/tests/test_settings.py b/src/paperless/tests/test_settings.py
index 5c257a08c..fe7356947 100644
--- a/src/paperless/tests/test_settings.py
+++ b/src/paperless/tests/test_settings.py
@@ -157,6 +157,7 @@ class TestCeleryScheduleParsing(TestCase):
INDEX_EXPIRE_TIME = 23.0 * 60.0 * 60.0
SANITY_EXPIRE_TIME = ((7.0 * 24.0) - 1.0) * 60.0 * 60.0
EMPTY_TRASH_EXPIRE_TIME = 23.0 * 60.0 * 60.0
+ RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME = 59.0 * 60.0
def test_schedule_configuration_default(self):
"""
@@ -196,6 +197,11 @@ class TestCeleryScheduleParsing(TestCase):
"schedule": crontab(minute=0, hour="1"),
"options": {"expires": self.EMPTY_TRASH_EXPIRE_TIME},
},
+ "Check and run scheduled workflows": {
+ "task": "documents.tasks.check_scheduled_workflows",
+ "schedule": crontab(minute="5", hour="*/1"),
+ "options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
+ },
},
schedule,
)
@@ -243,6 +249,11 @@ class TestCeleryScheduleParsing(TestCase):
"schedule": crontab(minute=0, hour="1"),
"options": {"expires": self.EMPTY_TRASH_EXPIRE_TIME},
},
+ "Check and run scheduled workflows": {
+ "task": "documents.tasks.check_scheduled_workflows",
+ "schedule": crontab(minute="5", hour="*/1"),
+ "options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
+ },
},
schedule,
)
@@ -282,6 +293,11 @@ class TestCeleryScheduleParsing(TestCase):
"schedule": crontab(minute=0, hour="1"),
"options": {"expires": self.EMPTY_TRASH_EXPIRE_TIME},
},
+ "Check and run scheduled workflows": {
+ "task": "documents.tasks.check_scheduled_workflows",
+ "schedule": crontab(minute="5", hour="*/1"),
+ "options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
+ },
},
schedule,
)
@@ -303,6 +319,7 @@ class TestCeleryScheduleParsing(TestCase):
"PAPERLESS_SANITY_TASK_CRON": "disable",
"PAPERLESS_INDEX_TASK_CRON": "disable",
"PAPERLESS_EMPTY_TRASH_TASK_CRON": "disable",
+ "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable",
},
):
schedule = _parse_beat_schedule()