diff --git a/Pipfile.lock b/Pipfile.lock index 97653fc54..baa2bad97 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e4cb2328c49829f56793ef25780dcc73ea8e4838e6e9bc25d1b6feb74eb3befe" + "sha256": "584249cbeaf29659c975000b5e02b12e45d768d795e4a8ac36118e73bd7c0b8a" }, "pipfile-spec": 6, "requires": {}, diff --git a/docs/usage.md b/docs/usage.md index 8f22ec3eb..7a93e16bc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -331,8 +331,10 @@ Currently, there are three events that correspond to workflow trigger 'types': 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. +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. -The following flow diagram illustrates the three trigger types: +The following flow diagram illustrates the three document trigger types: ```mermaid flowchart TD diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 8bd52760e..3357ffc45 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -1213,19 +1213,19 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 174 + 200 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 193 + 219 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 260 + 286 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 279 + 305 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1248,19 +1248,19 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 182 + 208 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 201 + 227 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 268 + 294 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 287 + 313 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1286,11 +1286,11 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 207 + 233 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 293 + 319 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1991,6 +1991,10 @@ src/app/components/common/dates-dropdown/dates-dropdown.component.html 11 + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 59 + src/app/components/document-list/document-list.component.html 239 @@ -3482,6 +3486,10 @@ src/app/components/common/dates-dropdown/dates-dropdown.component.html 74 + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 55 + src/app/components/document-list/document-list.component.html 248 @@ -3581,7 +3589,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 137 + 163 @@ -3998,7 +4006,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 162 + 188 @@ -4016,7 +4024,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 163 + 189 @@ -4462,322 +4470,417 @@ 121 + + Set scheduled trigger offset and which field to use. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 123 + + + + Offset days + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 126 + + + + Use 0 for immediate. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 126 + + + + Relative to + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 129 + + + + Delay custom field + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 133 + + + + Custom field to use for date. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 133 + + + + Recurring + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 139 + + + + Trigger is recurring. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 139 + + + + Recurring interval days + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 143 + + + + Repeat the trigger every n days. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 143 + + Trigger for documents that match all filters specified below. src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 122 + 148 Filter filename src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 125 + 151 Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive. src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 125 + 151 Filter sources src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 127 + 153 Filter path src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 128 + 154 Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a> src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 128 + 154 Filter mail rule src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 129 + 155 Apply to documents consumed via this mail rule. src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 129 + 155 Content matching algorithm src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 132 + 158 Content matching pattern src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 134 + 160 Has any of tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 143 + 169 Has correspondent src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 144 + 170 Has document type src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 145 + 171 Action type src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 155 + 181 Assign title src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 160 + 186 Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>. src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 160 + 186 Assign tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 161 + 187 Assign storage path src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 164 + 190 Assign custom fields src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 165 + 191 Assign owner src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 168 + 194 Assign view permissions src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 170 + 196 Assign edit permissions src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 189 + 215 Remove tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 216 + 242 Remove all src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 217 + 243 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 223 + 249 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 229 + 255 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 235 + 261 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 241 + 267 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 248 + 274 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 254 + 280 Remove correspondents src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 222 + 248 Remove document types src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 228 + 254 Remove storage paths src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 234 + 260 Remove custom fields src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 240 + 266 Remove owners src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 247 + 273 Remove permissions src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 253 + 279 View permissions src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 256 + 282 Edit permissions src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html - 275 + 301 Consume Folder src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 39 + 40 API Upload src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 43 + 44 Mail Fetch src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 47 + 48 + + + + Modified + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 63 + + + src/app/data/document.ts + 99 + + + + Custom Field + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 67 Consumption Started src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 54 + 74 Document Added src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 58 + 78 Document Updated src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 62 + 82 + + + + Scheduled + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 86 Assignment src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 69 + 93 Removal src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 73 + 97 Create new workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 142 + 172 Edit workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 146 + 176 @@ -8480,13 +8583,6 @@ 46 - - Modified - - src/app/data/document.ts - 99 - - Search score diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index a3bea36e7..907af6c9e 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -119,6 +119,32 @@
+ @if (formGroup.get('type').value === WorkflowTriggerType.Scheduled) { +

Set scheduled trigger offset and which field to use.

+
+
+ +
+
+ +
+ @if (formGroup.get('schedule_date_field').value === 'custom_field') { +
+ +
+ } +
+
+
+ +
+
+ @if (formGroup.get('schedule_is_recurring').value === true) { + + } +
+
+ }

Trigger for documents that match all filters specified below.

@@ -128,7 +154,7 @@ } - @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) { @if (patternRequired) { @@ -138,7 +164,7 @@ } }
- @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()