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,