mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-10 02:16:12 -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