mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Support CF queries!
This commit is contained in:
		| @@ -1,28 +1,36 @@ | ||||
| <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> | ||||
|   <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> | ||||
|     <i-bs name="{{icon}}"></i-bs> | ||||
|     <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|     @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> | ||||
| @if (useDropdown) { | ||||
|   <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> | ||||
|       <i-bs name="{{icon}}"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|       @if (isActive) { | ||||
|         <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> | ||||
|       } | ||||
|     </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> | ||||
| } @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"> | ||||
|   @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { | ||||
|   | ||||
| @@ -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 | ||||
|   } | ||||
|   | ||||
| @@ -210,6 +210,19 @@ | ||||
|                           [title]="null" | ||||
|                           formControlName="values" | ||||
|                         ></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 { | ||||
|                         <pngx-input-select | ||||
|                           [items]="getConditionSelectItems(condition.get('type').value)" | ||||
|   | ||||
| @@ -410,11 +410,7 @@ describe('WorkflowEditDialogComponent', () => { | ||||
|     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', () => { | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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("$") | ||||
|   | ||||
| @@ -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", | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon