From 88fcc5f3399c482b3b4454380ec3bf6317ec70ed Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:53:47 -0700 Subject: [PATCH] Support CF queries! --- ...ustom-fields-query-dropdown.component.html | 52 +++-- .../custom-fields-query-dropdown.component.ts | 3 + .../workflow-edit-dialog.component.html | 13 ++ .../workflow-edit-dialog.component.spec.ts | 40 +++- .../workflow-edit-dialog.component.ts | 208 +++++++++++++++--- src-ui/src/app/data/workflow-trigger.ts | 2 + src/documents/matching.py | 33 +++ ...ger_filter_custom_field_query_and_more.py} | 12 +- src/documents/models.py | 7 + src/documents/serialisers.py | 16 ++ src/documents/tests/test_api_workflows.py | 17 ++ src/documents/tests/test_workflows.py | 110 +++++++++ 12 files changed, 457 insertions(+), 56 deletions(-) rename src/documents/migrations/{1072_workflowtrigger_filter_has_all_tags_and_more.py => 1072_workflowtrigger_filter_custom_field_query_and_more.py} (83%) diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html index 57aff1bd9..a8973e702 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html @@ -1,28 +1,36 @@ -
- -
-
- @for (element of selectionModel.queries; track element.id; let i = $index) { -
- @switch (element.type) { - @case (CustomFieldQueryComponentType.Atom) { - - } - @case (CustomFieldQueryComponentType.Expression) { - - } - } -
+@if (useDropdown) { +
+ +
+
-
+} @else { + +} + + +
+ @for (element of queries; track element.id; let i = $index) { +
+ @switch (element.type) { + @case (CustomFieldQueryComponentType.Atom) { + + } + @case (CustomFieldQueryComponentType.Expression) { + + } + } +
+ } +
+
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts index ef56d6ac5..9b58114f7 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts @@ -206,6 +206,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm @Input() applyOnClose = false + @Input() + useDropdown: boolean = true + get name(): string { return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null } 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 0815a268b..c46a5d319 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 @@ -210,6 +210,19 @@ [title]="null" formControlName="values" > + } @else if ( + isCustomFieldQueryCondition(condition.get('type').value) + ) { + + @if (!isCustomFieldQueryValid(condition)) { +
+ Complete the custom field query configuration. +
+ } } @else { { conditions.at(2).get('values').setValue([4]) const addConditionOfType = (type: TriggerConditionType) => { - component.addCondition(triggerGroup as FormGroup) - const conditionArray = component.getConditionsFormArray( - triggerGroup as FormGroup - ) - const newCondition = conditionArray.at(conditionArray.length - 1) + const newCondition = component.addCondition(triggerGroup as FormGroup) newCondition.get('type').setValue(type) return newCondition } @@ -447,6 +443,12 @@ describe('WorkflowEditDialogComponent', () => { ) storagePathNot.get('values').setValue([1]) + const customFieldCondition = addConditionOfType( + TriggerConditionType.CustomFieldQuery + ) + const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]]) + customFieldCondition.get('values').setValue(customFieldQuery) + const formValues = component['getFormValues']() expect(formValues.triggers[0].filter_has_tags).toEqual([1]) @@ -458,6 +460,9 @@ describe('WorkflowEditDialogComponent', () => { expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1]) expect(formValues.triggers[0].filter_has_storage_path).toEqual(1) expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1]) + expect(formValues.triggers[0].filter_custom_field_query).toEqual( + customFieldQuery + ) expect(formValues.triggers[0].conditions).toBeUndefined() }) @@ -506,12 +511,22 @@ describe('WorkflowEditDialogComponent', () => { trigger.filter_has_not_document_types = [8] as any trigger.filter_has_storage_path = 9 as any trigger.filter_has_not_storage_paths = [10] as any + trigger.filter_custom_field_query = JSON.stringify([ + 'AND', + [[1, 'exact', 'value']], + ]) as any component.object = workflow component.ngOnInit() const triggerGroup = component.triggerFields.at(0) as FormGroup const conditions = component.getConditionsFormArray(triggerGroup) - expect(conditions.length).toBe(9) + expect(conditions.length).toBe(10) + const customFieldCondition = conditions.at(9) as FormGroup + expect(customFieldCondition.get('type').value).toBe( + TriggerConditionType.CustomFieldQuery + ) + const model = component.getCustomFieldQueryModel(customFieldCondition) + expect(model.isValid()).toBe(true) }) it('should expose select metadata helpers', () => { @@ -538,6 +553,12 @@ describe('WorkflowEditDialogComponent', () => { expect( component.getConditionSelectItems(TriggerConditionType.TagsAll) ).toEqual([]) + + expect( + component.isCustomFieldQueryCondition( + TriggerConditionType.CustomFieldQuery + ) + ).toBe(true) }) it('should normalize condition values for single and multi selects', () => { @@ -562,6 +583,13 @@ describe('WorkflowEditDialogComponent', () => { 8 ) ).toEqual(8) + const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]]) + expect( + component['normalizeConditionValue']( + TriggerConditionType.CustomFieldQuery, + customFieldJson + ) + ).toEqual(customFieldJson) }) it('should add and remove condition form groups', () => { 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 44705b2f6..33b8fb10b 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 @@ -6,6 +6,7 @@ import { import { NgTemplateOutlet } from '@angular/common' import { Component, OnInit, inject } from '@angular/core' import { + AbstractControl, FormArray, FormControl, FormGroup, @@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { UserService } from 'src/app/services/rest/user.service' import { WorkflowService } from 'src/app/services/rest/workflow.service' import { SettingsService } from 'src/app/services/settings.service' +import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' +import { + CustomFieldQueriesModel, + CustomFieldsQueryDropdownComponent, +} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component' import { CheckComponent } from '../../input/check/check.component' import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' import { EntriesComponent } from '../../input/entries/entries.component' @@ -145,18 +151,23 @@ export enum TriggerConditionType { DocumentTypeNot = 'document_type_not', StoragePathIs = 'storage_path_is', StoragePathNot = 'storage_path_not', + CustomFieldQuery = 'custom_field_query', } interface TriggerConditionDefinition { id: TriggerConditionType name: string - inputType: 'tags' | 'select' + inputType: 'tags' | 'select' | 'customFieldQuery' allowMultipleEntries: boolean allowMultipleValues: boolean selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths' disabled?: boolean } +type TriggerConditionOption = TriggerConditionDefinition & { + disabled?: boolean +} + const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [ { id: TriggerConditionType.TagsAny, @@ -227,6 +238,13 @@ const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [ allowMultipleValues: true, selectItems: 'storagePaths', }, + { + id: TriggerConditionType.CustomFieldQuery, + name: $localize`Matches custom field query`, + inputType: 'customFieldQuery', + allowMultipleEntries: false, + allowMultipleValues: false, + }, ] const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( @@ -247,6 +265,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( TextAreaComponent, TagsComponent, CustomFieldsValuesComponent, + CustomFieldsQueryDropdownComponent, PermissionsGroupComponent, PermissionsUserComponent, ConfirmButtonComponent, @@ -287,7 +306,12 @@ export class WorkflowEditDialogComponent private conditionTypeOptionCache = new WeakMap< FormArray, - TriggerConditionDefinition[] + TriggerConditionOption[] + >() + + private customFieldQueryModels = new WeakMap< + FormGroup, + CustomFieldQueriesModel >() constructor() { @@ -510,6 +534,7 @@ export class WorkflowEditDialogComponent filter_has_correspondent: null as number | null, filter_has_document_type: null as number | null, filter_has_storage_path: null as number | null, + filter_custom_field_query: null as string | null, } conditions.controls.forEach((control) => { @@ -558,6 +583,9 @@ export class WorkflowEditDialogComponent case TriggerConditionType.StoragePathNot: aggregate.filter_has_not_storage_paths = [...values] break + case TriggerConditionType.CustomFieldQuery: + aggregate.filter_custom_field_query = values as string + break } }) @@ -576,6 +604,8 @@ export class WorkflowEditDialogComponent aggregate.filter_has_document_type ?? null trigger.filter_has_storage_path = aggregate.filter_has_storage_path ?? null + trigger.filter_custom_field_query = + aggregate.filter_custom_field_query ?? null delete trigger.conditions @@ -593,7 +623,7 @@ export class WorkflowEditDialogComponent private createConditionFormGroup( type: TriggerConditionType, - initialValue?: number | number[] + initialValue?: any ): FormGroup { const group = new FormGroup({ type: new FormControl(type), @@ -603,11 +633,20 @@ export class WorkflowEditDialogComponent group .get('type') .valueChanges.subscribe((newType: TriggerConditionType) => { - group.get('values').setValue(this.getDefaultConditionValue(newType), { - emitEvent: false, - }) + if (newType === TriggerConditionType.CustomFieldQuery) { + this.ensureCustomFieldQueryModel(group) + } else { + this.teardownCustomFieldQueryModel(group) + group.get('values').setValue(this.getDefaultConditionValue(newType), { + emitEvent: false, + }) + } }) + if (type === TriggerConditionType.CustomFieldQuery) { + this.ensureCustomFieldQueryModel(group, initialValue) + } + return group } @@ -704,6 +743,15 @@ export class WorkflowEditDialogComponent ) } + if (trigger.filter_custom_field_query) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.CustomFieldQuery, + trigger.filter_custom_field_query + ) + ) + } + return conditions } @@ -753,10 +801,10 @@ export class WorkflowEditDialogComponent }) } - addCondition(triggerFormGroup: FormGroup) { + addCondition(triggerFormGroup: FormGroup): FormGroup | null { const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup) if (triggerIndex === -1) { - return + return null } const conditions = this.getConditionsFormArray(triggerFormGroup) @@ -771,12 +819,14 @@ export class WorkflowEditDialogComponent }) if (!availableDefinition) { - return + return null } conditions.push(this.createConditionFormGroup(availableDefinition.id)) triggerFormGroup.markAsDirty() triggerFormGroup.markAsTouched() + + return conditions.at(conditions.length - 1) as FormGroup } removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) { @@ -786,6 +836,13 @@ export class WorkflowEditDialogComponent } const conditions = this.getConditionsFormArray(triggerFormGroup) + const conditionGroup = conditions.at(conditionIndex) as FormGroup + if ( + conditionGroup?.get('type').value === + TriggerConditionType.CustomFieldQuery + ) { + this.teardownCustomFieldQueryModel(conditionGroup) + } conditions.removeAt(conditionIndex) triggerFormGroup.markAsDirty() triggerFormGroup.markAsTouched() @@ -807,6 +864,10 @@ export class WorkflowEditDialogComponent return this.getConditionDefinition(type)?.inputType === 'tags' } + isCustomFieldQueryCondition(type: TriggerConditionType): boolean { + return this.getConditionDefinition(type)?.inputType === 'customFieldQuery' + } + isMultiValueCondition(type: TriggerConditionType): boolean { switch (type) { case TriggerConditionType.TagsAny: @@ -843,18 +904,124 @@ export class WorkflowEditDialogComponent } } + getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel { + const conditionGroup = control as FormGroup + this.ensureCustomFieldQueryModel(conditionGroup) + return this.customFieldQueryModels.get(conditionGroup) + } + + onCustomFieldQuerySelectionChange( + control: AbstractControl, + model: CustomFieldQueriesModel + ) { + this.onCustomFieldQueryModelChanged(control as FormGroup, model) + } + + isCustomFieldQueryValid(control: AbstractControl): boolean { + const model = this.customFieldQueryModels.get(control as FormGroup) + if (!model) { + return true + } + + return model.isEmpty() || model.isValid() + } + + private getConditionTypeOptionsForArray( + conditions: FormArray + ): TriggerConditionOption[] { + let cached = this.conditionTypeOptionCache.get(conditions) + if (!cached) { + cached = this.conditionDefinitions.map((definition) => ({ + ...definition, + disabled: false, + })) + this.conditionTypeOptionCache.set(conditions, cached) + } + return cached + } + + private ensureCustomFieldQueryModel( + conditionGroup: FormGroup, + initialValue?: any + ) { + if (this.customFieldQueryModels.has(conditionGroup)) { + return + } + + const model = new CustomFieldQueriesModel() + this.customFieldQueryModels.set(conditionGroup, model) + + const rawValue = + typeof initialValue === 'string' + ? initialValue + : (conditionGroup.get('values').value as string) + + if (rawValue) { + try { + const parsed = JSON.parse(rawValue) + const expression = new CustomFieldQueryExpression(parsed) + model.queries = [expression] + } catch (error) { + model.clear(false) + } + } + + model.changed.subscribe(() => { + this.onCustomFieldQueryModelChanged(conditionGroup, model) + }) + + this.onCustomFieldQueryModelChanged(conditionGroup, model) + } + + private teardownCustomFieldQueryModel(conditionGroup: FormGroup) { + if (!this.customFieldQueryModels.has(conditionGroup)) { + return + } + this.customFieldQueryModels.delete(conditionGroup) + } + + private onCustomFieldQueryModelChanged( + conditionGroup: FormGroup, + model: CustomFieldQueriesModel + ) { + const control = conditionGroup.get('values') + if (!control) { + return + } + + if (!model.isValid()) { + control.setValue(null, { emitEvent: false }) + return + } + + if (model.isEmpty()) { + control.setValue(null, { emitEvent: false }) + return + } + + const serialized = JSON.stringify(model.queries[0].serialize()) + control.setValue(serialized, { emitEvent: false }) + } + private getDefaultConditionValue(type: TriggerConditionType) { + if (type === TriggerConditionType.CustomFieldQuery) { + return null + } return this.isMultiValueCondition(type) ? [] : null } - private normalizeConditionValue( - type: TriggerConditionType, - value?: number | number[] - ) { + private normalizeConditionValue(type: TriggerConditionType, value?: any) { if (value === undefined || value === null) { return this.getDefaultConditionValue(type) } + if (type === TriggerConditionType.CustomFieldQuery) { + if (typeof value === 'string') { + return value + } + return value ? JSON.stringify(value) : null + } + if (this.isMultiValueCondition(type)) { return Array.isArray(value) ? [...value] : [value] } @@ -866,20 +1033,6 @@ export class WorkflowEditDialogComponent return value } - private getConditionTypeOptionsForArray( - conditions: FormArray - ): TriggerConditionDefinition[] { - let cached = this.conditionTypeOptionCache.get(conditions) - if (!cached) { - cached = this.conditionDefinitions.map((definition) => ({ - ...definition, - disabled: false, - })) - this.conditionTypeOptionCache.set(conditions, cached) - } - return cached - } - private createTriggerField( trigger: WorkflowTrigger, emitEvent: boolean = false @@ -1032,6 +1185,7 @@ export class WorkflowEditDialogComponent filter_has_not_correspondents: [], filter_has_not_document_types: [], filter_has_not_storage_paths: [], + filter_custom_field_query: null, filter_has_correspondent: null, filter_has_document_type: null, filter_has_storage_path: null, diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index d2f0d90b9..888b18cc3 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -50,6 +50,8 @@ export interface WorkflowTrigger extends ObjectWithId { filter_has_not_storage_paths?: number[] // StoragePath.id[] + filter_custom_field_query?: string + filter_has_correspondent?: number // Correspondent.id filter_has_document_type?: number // DocumentType.id diff --git a/src/documents/matching.py b/src/documents/matching.py index 7e0322621..81e6d65bc 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -6,8 +6,11 @@ from fnmatch import fnmatch from fnmatch import translate as fnmatch_translate from typing import TYPE_CHECKING +from rest_framework import serializers + from documents.data_models import ConsumableDocument from documents.data_models import DocumentSource +from documents.filters import CustomFieldQueryParser from documents.models import Correspondent from documents.models import Document from documents.models import DocumentType @@ -475,6 +478,25 @@ def existing_document_matches_workflow( ) trigger_matched = False + if trigger_matched and trigger.filter_custom_field_query: + parser = CustomFieldQueryParser("filter_custom_field_query") + try: + custom_field_q, annotations = parser.parse( + trigger.filter_custom_field_query, + ) + except serializers.ValidationError: + reason = "Invalid custom field query configuration" + trigger_matched = False + else: + qs = ( + Document.objects.filter(id=document.id) + .annotate(**annotations) + .filter(custom_field_q) + ) + if not qs.exists(): + reason = "Document custom fields do not match the configured custom field query" + trigger_matched = False + # Document original_filename vs trigger filename if ( trigger.filter_filename is not None @@ -549,6 +571,17 @@ def prefilter_documents_by_workflowtrigger( storage_path__in=trigger.filter_has_not_storage_paths.all(), ) + if trigger.filter_custom_field_query: + parser = CustomFieldQueryParser("filter_custom_field_query") + try: + custom_field_q, annotations = parser.parse( + trigger.filter_custom_field_query, + ) + except serializers.ValidationError: + return documents.none() + + documents = documents.annotate(**annotations).filter(custom_field_q) + 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("$") diff --git a/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py b/src/documents/migrations/1072_workflowtrigger_filter_custom_field_query_and_more.py similarity index 83% rename from src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py rename to src/documents/migrations/1072_workflowtrigger_filter_custom_field_query_and_more.py index c042035d2..1a22f6b4f 100644 --- a/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py +++ b/src/documents/migrations/1072_workflowtrigger_filter_custom_field_query_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-10-07 16:22 +# Generated by Django 5.2.6 on 2025-10-07 18:52 from django.db import migrations from django.db import models @@ -10,6 +10,16 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name="workflowtrigger", + name="filter_custom_field_query", + field=models.TextField( + blank=True, + help_text="JSON-encoded custom field query expression.", + null=True, + verbose_name="filter custom field query", + ), + ), migrations.AddField( model_name="workflowtrigger", name="filter_has_all_tags", diff --git a/src/documents/models.py b/src/documents/models.py index 28b9e1be2..ea8662023 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1124,6 +1124,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("does not have these storage path(s)"), ) + filter_custom_field_query = models.TextField( + _("filter custom field query"), + null=True, + blank=True, + help_text=_("JSON-encoded custom field query expression."), + ) + schedule_offset_days = models.IntegerField( _("schedule offset days"), default=0, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 6fb09c718..f4f97428c 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -43,6 +43,7 @@ if settings.AUDIT_LOG_ENABLED: from documents import bulk_edit from documents.data_models import DocumentSource +from documents.filters import CustomFieldQueryParser from documents.models import Correspondent from documents.models import CustomField from documents.models import CustomFieldInstance @@ -2196,6 +2197,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): "filter_has_tags", "filter_has_all_tags", "filter_has_not_tags", + "filter_custom_field_query", "filter_has_not_correspondents", "filter_has_not_document_types", "filter_has_not_storage_paths", @@ -2224,6 +2226,20 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): ): attrs["filter_path"] = None + if ( + "filter_custom_field_query" in attrs + and attrs["filter_custom_field_query"] is not None + and len(attrs["filter_custom_field_query"]) == 0 + ): + attrs["filter_custom_field_query"] = None + + if ( + "filter_custom_field_query" in attrs + and attrs["filter_custom_field_query"] is not None + ): + parser = CustomFieldQueryParser("filter_custom_field_query") + parser.parse(attrs["filter_custom_field_query"]) + trigger_type = attrs.get("type", getattr(self.instance, "type", None)) if ( trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index 1e3dc6f09..9efdb8451 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -189,6 +189,12 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_not_correspondents": [self.c2.id], "filter_has_not_document_types": [self.dt2.id], "filter_has_not_storage_paths": [self.sp2.id], + "filter_custom_field_query": json.dumps( + [ + "AND", + [[self.cf1.id, "exact", "value"]], + ], + ), "filter_has_document_type": self.dt.id, "filter_has_correspondent": self.c.id, "filter_has_storage_path": self.sp.id, @@ -254,6 +260,10 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)), {self.sp2.id}, ) + self.assertEqual( + trigger.filter_custom_field_query, + json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]), + ) def test_api_create_invalid_workflow_trigger(self): """ @@ -412,6 +422,9 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_not_correspondents": [self.c2.id], "filter_has_not_document_types": [self.dt2.id], "filter_has_not_storage_paths": [self.sp2.id], + "filter_custom_field_query": json.dumps( + ["AND", [[self.cf1.id, "exact", "value"]]], + ), "filter_has_correspondent": self.c.id, "filter_has_document_type": self.dt.id, }, @@ -449,6 +462,10 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): workflow.triggers.first().filter_has_not_storage_paths.first(), self.sp2, ) + self.assertEqual( + workflow.triggers.first().filter_custom_field_query, + json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]), + ) self.assertEqual(workflow.actions.first().assign_title, "Action New Title") def test_api_update_workflow_no_trigger_actions(self): diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 607ba47cc..af3849ae9 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -1,4 +1,5 @@ import datetime +import json import shutil import socket from datetime import timedelta @@ -31,6 +32,7 @@ from documents import tasks from documents.data_models import ConsumableDocument from documents.data_models import DocumentSource from documents.matching import document_matches_workflow +from documents.matching import existing_document_matches_workflow from documents.matching import prefilter_documents_by_workflowtrigger from documents.models import Correspondent from documents.models import CustomField @@ -1267,6 +1269,114 @@ class TestWorkflows( ) self.assertIn(expected_str, cm.output[1]) + def test_document_added_custom_field_query_no_match(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_custom_field_query=json.dumps( + [ + "AND", + [[self.cf1.id, "exact", "expected"]], + ], + ), + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + workflow = Workflow.objects.create(name="Workflow 1", order=0) + workflow.triggers.add(trigger) + workflow.actions.add(action) + workflow.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + CustomFieldInstance.objects.create( + document=doc, + field=self.cf1, + value_text="other", + ) + + 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 {workflow}" + self.assertIn(expected_str, cm.output[0]) + self.assertIn( + "Document custom fields do not match the configured custom field query", + cm.output[1], + ) + + def test_document_added_custom_field_query_match(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_custom_field_query=json.dumps( + [ + "AND", + [[self.cf1.id, "exact", "expected"]], + ], + ), + ) + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + CustomFieldInstance.objects.create( + document=doc, + field=self.cf1, + value_text="expected", + ) + + matched, reason = existing_document_matches_workflow(doc, trigger) + self.assertTrue(matched) + self.assertEqual(reason, "") + + def test_prefilter_documents_custom_field_query(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_custom_field_query=json.dumps( + [ + "AND", + [[self.cf1.id, "exact", "match"]], + ], + ), + ) + doc1 = Document.objects.create( + title="doc 1", + correspondent=self.c, + original_filename="doc1.pdf", + checksum="checksum1", + ) + CustomFieldInstance.objects.create( + document=doc1, + field=self.cf1, + value_text="match", + ) + + doc2 = Document.objects.create( + title="doc 2", + correspondent=self.c, + original_filename="doc2.pdf", + checksum="checksum2", + ) + CustomFieldInstance.objects.create( + document=doc2, + field=self.cf1, + value_text="different", + ) + + filtered = prefilter_documents_by_workflowtrigger( + Document.objects.all(), + trigger, + ) + self.assertIn(doc1, filtered) + self.assertNotIn(doc2, filtered) + def test_document_added_no_match_doctype(self): trigger = WorkflowTrigger.objects.create( type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,