Support CF queries!

This commit is contained in:
shamoon
2025-10-07 11:53:47 -07:00
parent 3d9cf696a7
commit 88fcc5f339
12 changed files with 457 additions and 56 deletions

View File

@@ -1,28 +1,36 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> @if (useDropdown) {
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<i-bs name="{{icon}}"></i-bs> <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <i-bs name="{{icon}}"></i-bs>
@if (isActive) { <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> @if (isActive) {
} <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
} }
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
</div> </div>
</div> </div>
</div> } @else {
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
}
<ng-template #list let-queries="queries">
<div class="list-group list-group-flush">
@for (element of queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</ng-template>
<ng-template #comparisonValueTemplate let-atom="atom"> <ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {

View File

@@ -206,6 +206,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Input() @Input()
applyOnClose = false applyOnClose = false
@Input()
useDropdown: boolean = true
get name(): string { get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }

View File

@@ -210,6 +210,19 @@
[title]="null" [title]="null"
formControlName="values" formControlName="values"
></pngx-input-tags> ></pngx-input-tags>
} @else if (
isCustomFieldQueryCondition(condition.get('type').value)
) {
<pngx-custom-fields-query-dropdown
[selectionModel]="getCustomFieldQueryModel(condition)"
(selectionModelChange)="onCustomFieldQuerySelectionChange(condition, $event)"
[useDropdown]="false"
></pngx-custom-fields-query-dropdown>
@if (!isCustomFieldQueryValid(condition)) {
<div class="text-danger small" i18n>
Complete the custom field query configuration.
</div>
}
} @else { } @else {
<pngx-input-select <pngx-input-select
[items]="getConditionSelectItems(condition.get('type').value)" [items]="getConditionSelectItems(condition.get('type').value)"

View File

@@ -410,11 +410,7 @@ describe('WorkflowEditDialogComponent', () => {
conditions.at(2).get('values').setValue([4]) conditions.at(2).get('values').setValue([4])
const addConditionOfType = (type: TriggerConditionType) => { const addConditionOfType = (type: TriggerConditionType) => {
component.addCondition(triggerGroup as FormGroup) const newCondition = component.addCondition(triggerGroup as FormGroup)
const conditionArray = component.getConditionsFormArray(
triggerGroup as FormGroup
)
const newCondition = conditionArray.at(conditionArray.length - 1)
newCondition.get('type').setValue(type) newCondition.get('type').setValue(type)
return newCondition return newCondition
} }
@@ -447,6 +443,12 @@ describe('WorkflowEditDialogComponent', () => {
) )
storagePathNot.get('values').setValue([1]) 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']() const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([1]) 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_not_document_types).toEqual([1])
expect(formValues.triggers[0].filter_has_storage_path).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_has_not_storage_paths).toEqual([1])
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
customFieldQuery
)
expect(formValues.triggers[0].conditions).toBeUndefined() expect(formValues.triggers[0].conditions).toBeUndefined()
}) })
@@ -506,12 +511,22 @@ describe('WorkflowEditDialogComponent', () => {
trigger.filter_has_not_document_types = [8] as any trigger.filter_has_not_document_types = [8] as any
trigger.filter_has_storage_path = 9 as any trigger.filter_has_storage_path = 9 as any
trigger.filter_has_not_storage_paths = [10] 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.object = workflow
component.ngOnInit() component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup const triggerGroup = component.triggerFields.at(0) as FormGroup
const conditions = component.getConditionsFormArray(triggerGroup) 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', () => { it('should expose select metadata helpers', () => {
@@ -538,6 +553,12 @@ describe('WorkflowEditDialogComponent', () => {
expect( expect(
component.getConditionSelectItems(TriggerConditionType.TagsAll) component.getConditionSelectItems(TriggerConditionType.TagsAll)
).toEqual([]) ).toEqual([])
expect(
component.isCustomFieldQueryCondition(
TriggerConditionType.CustomFieldQuery
)
).toBe(true)
}) })
it('should normalize condition values for single and multi selects', () => { it('should normalize condition values for single and multi selects', () => {
@@ -562,6 +583,13 @@ describe('WorkflowEditDialogComponent', () => {
8 8
) )
).toEqual(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', () => { it('should add and remove condition form groups', () => {

View File

@@ -6,6 +6,7 @@ import {
import { NgTemplateOutlet } from '@angular/common' import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { import {
AbstractControl,
FormArray, FormArray,
FormControl, FormControl,
FormGroup, 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 { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.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 { 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 { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component' import { EntriesComponent } from '../../input/entries/entries.component'
@@ -145,18 +151,23 @@ export enum TriggerConditionType {
DocumentTypeNot = 'document_type_not', DocumentTypeNot = 'document_type_not',
StoragePathIs = 'storage_path_is', StoragePathIs = 'storage_path_is',
StoragePathNot = 'storage_path_not', StoragePathNot = 'storage_path_not',
CustomFieldQuery = 'custom_field_query',
} }
interface TriggerConditionDefinition { interface TriggerConditionDefinition {
id: TriggerConditionType id: TriggerConditionType
name: string name: string
inputType: 'tags' | 'select' inputType: 'tags' | 'select' | 'customFieldQuery'
allowMultipleEntries: boolean allowMultipleEntries: boolean
allowMultipleValues: boolean allowMultipleValues: boolean
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths' selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
disabled?: boolean disabled?: boolean
} }
type TriggerConditionOption = TriggerConditionDefinition & {
disabled?: boolean
}
const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [ const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [
{ {
id: TriggerConditionType.TagsAny, id: TriggerConditionType.TagsAny,
@@ -227,6 +238,13 @@ const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [
allowMultipleValues: true, allowMultipleValues: true,
selectItems: 'storagePaths', selectItems: 'storagePaths',
}, },
{
id: TriggerConditionType.CustomFieldQuery,
name: $localize`Matches custom field query`,
inputType: 'customFieldQuery',
allowMultipleEntries: false,
allowMultipleValues: false,
},
] ]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
@@ -247,6 +265,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
TextAreaComponent, TextAreaComponent,
TagsComponent, TagsComponent,
CustomFieldsValuesComponent, CustomFieldsValuesComponent,
CustomFieldsQueryDropdownComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
ConfirmButtonComponent, ConfirmButtonComponent,
@@ -287,7 +306,12 @@ export class WorkflowEditDialogComponent
private conditionTypeOptionCache = new WeakMap< private conditionTypeOptionCache = new WeakMap<
FormArray, FormArray,
TriggerConditionDefinition[] TriggerConditionOption[]
>()
private customFieldQueryModels = new WeakMap<
FormGroup,
CustomFieldQueriesModel
>() >()
constructor() { constructor() {
@@ -510,6 +534,7 @@ export class WorkflowEditDialogComponent
filter_has_correspondent: null as number | null, filter_has_correspondent: null as number | null,
filter_has_document_type: null as number | null, filter_has_document_type: null as number | null,
filter_has_storage_path: null as number | null, filter_has_storage_path: null as number | null,
filter_custom_field_query: null as string | null,
} }
conditions.controls.forEach((control) => { conditions.controls.forEach((control) => {
@@ -558,6 +583,9 @@ export class WorkflowEditDialogComponent
case TriggerConditionType.StoragePathNot: case TriggerConditionType.StoragePathNot:
aggregate.filter_has_not_storage_paths = [...values] aggregate.filter_has_not_storage_paths = [...values]
break 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 aggregate.filter_has_document_type ?? null
trigger.filter_has_storage_path = trigger.filter_has_storage_path =
aggregate.filter_has_storage_path ?? null aggregate.filter_has_storage_path ?? null
trigger.filter_custom_field_query =
aggregate.filter_custom_field_query ?? null
delete trigger.conditions delete trigger.conditions
@@ -593,7 +623,7 @@ export class WorkflowEditDialogComponent
private createConditionFormGroup( private createConditionFormGroup(
type: TriggerConditionType, type: TriggerConditionType,
initialValue?: number | number[] initialValue?: any
): FormGroup { ): FormGroup {
const group = new FormGroup({ const group = new FormGroup({
type: new FormControl(type), type: new FormControl(type),
@@ -603,11 +633,20 @@ export class WorkflowEditDialogComponent
group group
.get('type') .get('type')
.valueChanges.subscribe((newType: TriggerConditionType) => { .valueChanges.subscribe((newType: TriggerConditionType) => {
group.get('values').setValue(this.getDefaultConditionValue(newType), { if (newType === TriggerConditionType.CustomFieldQuery) {
emitEvent: false, 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 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 return conditions
} }
@@ -753,10 +801,10 @@ export class WorkflowEditDialogComponent
}) })
} }
addCondition(triggerFormGroup: FormGroup) { addCondition(triggerFormGroup: FormGroup): FormGroup | null {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup) const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) { if (triggerIndex === -1) {
return return null
} }
const conditions = this.getConditionsFormArray(triggerFormGroup) const conditions = this.getConditionsFormArray(triggerFormGroup)
@@ -771,12 +819,14 @@ export class WorkflowEditDialogComponent
}) })
if (!availableDefinition) { if (!availableDefinition) {
return return null
} }
conditions.push(this.createConditionFormGroup(availableDefinition.id)) conditions.push(this.createConditionFormGroup(availableDefinition.id))
triggerFormGroup.markAsDirty() triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched() triggerFormGroup.markAsTouched()
return conditions.at(conditions.length - 1) as FormGroup
} }
removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) { removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) {
@@ -786,6 +836,13 @@ export class WorkflowEditDialogComponent
} }
const conditions = this.getConditionsFormArray(triggerFormGroup) 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) conditions.removeAt(conditionIndex)
triggerFormGroup.markAsDirty() triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched() triggerFormGroup.markAsTouched()
@@ -807,6 +864,10 @@ export class WorkflowEditDialogComponent
return this.getConditionDefinition(type)?.inputType === 'tags' return this.getConditionDefinition(type)?.inputType === 'tags'
} }
isCustomFieldQueryCondition(type: TriggerConditionType): boolean {
return this.getConditionDefinition(type)?.inputType === 'customFieldQuery'
}
isMultiValueCondition(type: TriggerConditionType): boolean { isMultiValueCondition(type: TriggerConditionType): boolean {
switch (type) { switch (type) {
case TriggerConditionType.TagsAny: 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) { private getDefaultConditionValue(type: TriggerConditionType) {
if (type === TriggerConditionType.CustomFieldQuery) {
return null
}
return this.isMultiValueCondition(type) ? [] : null return this.isMultiValueCondition(type) ? [] : null
} }
private normalizeConditionValue( private normalizeConditionValue(type: TriggerConditionType, value?: any) {
type: TriggerConditionType,
value?: number | number[]
) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return this.getDefaultConditionValue(type) return this.getDefaultConditionValue(type)
} }
if (type === TriggerConditionType.CustomFieldQuery) {
if (typeof value === 'string') {
return value
}
return value ? JSON.stringify(value) : null
}
if (this.isMultiValueCondition(type)) { if (this.isMultiValueCondition(type)) {
return Array.isArray(value) ? [...value] : [value] return Array.isArray(value) ? [...value] : [value]
} }
@@ -866,20 +1033,6 @@ export class WorkflowEditDialogComponent
return value 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( private createTriggerField(
trigger: WorkflowTrigger, trigger: WorkflowTrigger,
emitEvent: boolean = false emitEvent: boolean = false
@@ -1032,6 +1185,7 @@ export class WorkflowEditDialogComponent
filter_has_not_correspondents: [], filter_has_not_correspondents: [],
filter_has_not_document_types: [], filter_has_not_document_types: [],
filter_has_not_storage_paths: [], filter_has_not_storage_paths: [],
filter_custom_field_query: null,
filter_has_correspondent: null, filter_has_correspondent: null,
filter_has_document_type: null, filter_has_document_type: null,
filter_has_storage_path: null, filter_has_storage_path: null,

View File

@@ -50,6 +50,8 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_not_storage_paths?: number[] // StoragePath.id[] filter_has_not_storage_paths?: number[] // StoragePath.id[]
filter_custom_field_query?: string
filter_has_correspondent?: number // Correspondent.id filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id filter_has_document_type?: number // DocumentType.id

View File

@@ -6,8 +6,11 @@ from fnmatch import fnmatch
from fnmatch import translate as fnmatch_translate from fnmatch import translate as fnmatch_translate
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from rest_framework import serializers
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
@@ -475,6 +478,25 @@ def existing_document_matches_workflow(
) )
trigger_matched = False 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 # Document original_filename vs trigger filename
if ( if (
trigger.filter_filename is not None 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(), 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: 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 # the true fnmatch will actually run later so we just want a loose filter here
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$") regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")

View File

@@ -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 migrations
from django.db import models from django.db import models
@@ -10,6 +10,16 @@ class Migration(migrations.Migration):
] ]
operations = [ 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( migrations.AddField(
model_name="workflowtrigger", model_name="workflowtrigger",
name="filter_has_all_tags", name="filter_has_all_tags",

View File

@@ -1124,6 +1124,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("does not have these storage path(s)"), 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 = models.IntegerField(
_("schedule offset days"), _("schedule offset days"),
default=0, default=0,

View File

@@ -43,6 +43,7 @@ if settings.AUDIT_LOG_ENABLED:
from documents import bulk_edit from documents import bulk_edit
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@@ -2196,6 +2197,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"filter_has_tags", "filter_has_tags",
"filter_has_all_tags", "filter_has_all_tags",
"filter_has_not_tags", "filter_has_not_tags",
"filter_custom_field_query",
"filter_has_not_correspondents", "filter_has_not_correspondents",
"filter_has_not_document_types", "filter_has_not_document_types",
"filter_has_not_storage_paths", "filter_has_not_storage_paths",
@@ -2224,6 +2226,20 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
): ):
attrs["filter_path"] = None 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)) trigger_type = attrs.get("type", getattr(self.instance, "type", None))
if ( if (
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION

View File

@@ -189,6 +189,12 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_not_correspondents": [self.c2.id], "filter_has_not_correspondents": [self.c2.id],
"filter_has_not_document_types": [self.dt2.id], "filter_has_not_document_types": [self.dt2.id],
"filter_has_not_storage_paths": [self.sp2.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_document_type": self.dt.id,
"filter_has_correspondent": self.c.id, "filter_has_correspondent": self.c.id,
"filter_has_storage_path": self.sp.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)), set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)),
{self.sp2.id}, {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): 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_correspondents": [self.c2.id],
"filter_has_not_document_types": [self.dt2.id], "filter_has_not_document_types": [self.dt2.id],
"filter_has_not_storage_paths": [self.sp2.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_correspondent": self.c.id,
"filter_has_document_type": self.dt.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(), workflow.triggers.first().filter_has_not_storage_paths.first(),
self.sp2, 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") self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
def test_api_update_workflow_no_trigger_actions(self): def test_api_update_workflow_no_trigger_actions(self):

View File

@@ -1,4 +1,5 @@
import datetime import datetime
import json
import shutil import shutil
import socket import socket
from datetime import timedelta from datetime import timedelta
@@ -31,6 +32,7 @@ from documents import tasks
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.matching import document_matches_workflow 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.matching import prefilter_documents_by_workflowtrigger
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -1267,6 +1269,114 @@ class TestWorkflows(
) )
self.assertIn(expected_str, cm.output[1]) 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): def test_document_added_no_match_doctype(self):
trigger = WorkflowTrigger.objects.create( trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,