From 49654809589891d801dd81372db10b355ba0f2ca Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:42:00 -0700 Subject: [PATCH] Add negation for other things, universal query builder --- .../workflow-edit-dialog.component.html | 50 +- .../workflow-edit-dialog.component.spec.ts | 65 ++- .../workflow-edit-dialog.component.ts | 496 ++++++++++++++---- src-ui/src/app/data/workflow-trigger.ts | 6 + src/documents/matching.py | 105 +++- ...lowtrigger_filter_has_all_tags_and_more.py | 32 +- src/documents/models.py | 21 + src/documents/serialisers.py | 27 + src/documents/tests/test_api_workflows.py | 30 ++ src/documents/tests/test_workflows.py | 108 ++++ 10 files changed, 797 insertions(+), 143 deletions(-) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 84d0cd262..9c93226bb 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -172,32 +172,34 @@ } } + +
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { -
-
+
+
- +
-
- @if (getTagConditionsFormArray(formGroup).length === 0) { -

No tag conditions added. Add one to refine tag-based matching.

+
+ @if (getConditionsFormArray(formGroup).length === 0) { +

No conditions added. Add one to define document filters.

} - @for (condition of getTagConditionsFormArray(formGroup).controls; track condition; let conditionIndex = $index) { + @for (condition of getConditionsFormArray(formGroup).controls; track condition; let conditionIndex = $index) {
@@ -205,25 +207,33 @@
- + @if (isTagsCondition(condition.get('type').value)) { + + } @else { + + }
}
- - -
}
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index 0b8751148..47d191e8a 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -43,6 +43,7 @@ import { EditDialogMode } from '../edit-dialog.component' import { DOCUMENT_SOURCE_OPTIONS, SCHEDULE_DATE_FIELD_OPTIONS, + TriggerConditionType, WORKFLOW_ACTION_OPTIONS, WORKFLOW_TYPE_OPTIONS, WorkflowEditDialogComponent, @@ -375,29 +376,73 @@ describe('WorkflowEditDialogComponent', () => { expect(component.objectForm.get('actions').value[0].webhook).toBeNull() }) - it('should map tag condition builder values into trigger filters on save', () => { + it('should map condition builder values into trigger filters on save', () => { component.object = undefined component.addTrigger() const triggerGroup = component.triggerFields.at(0) - component.addTagCondition(0) - component.addTagCondition(0) - component.addTagCondition(0) + component.addCondition(triggerGroup as FormGroup) + component.addCondition(triggerGroup as FormGroup) + component.addCondition(triggerGroup as FormGroup) - const tagConditions = component.getTagConditionsFormArray( + const conditions = component.getConditionsFormArray( triggerGroup as FormGroup ) - expect(tagConditions.length).toBe(3) + expect(conditions.length).toBe(3) - tagConditions.at(0).get('tags').setValue([1]) - tagConditions.at(1).get('tags').setValue([2, 3]) - tagConditions.at(2).get('tags').setValue([4]) + conditions.at(0).get('values').setValue([1]) + conditions.at(1).get('values').setValue([2, 3]) + 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) + newCondition.get('type').setValue(type) + return newCondition + } + + const correspondentIs = addConditionOfType( + TriggerConditionType.CorrespondentIs + ) + correspondentIs.get('values').setValue(1) + + const correspondentNot = addConditionOfType( + TriggerConditionType.CorrespondentNot + ) + correspondentNot.get('values').setValue([1]) + + const documentTypeIs = addConditionOfType( + TriggerConditionType.DocumentTypeIs + ) + documentTypeIs.get('values').setValue(1) + + const documentTypeNot = addConditionOfType( + TriggerConditionType.DocumentTypeNot + ) + documentTypeNot.get('values').setValue([1]) + + const storagePathIs = addConditionOfType(TriggerConditionType.StoragePathIs) + storagePathIs.get('values').setValue(1) + + const storagePathNot = addConditionOfType( + TriggerConditionType.StoragePathNot + ) + storagePathNot.get('values').setValue([1]) const formValues = component['getFormValues']() expect(formValues.triggers[0].filter_has_tags).toEqual([1]) expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3]) expect(formValues.triggers[0].filter_has_not_tags).toEqual([4]) - expect(formValues.triggers[0].tagConditions).toBeUndefined() + expect(formValues.triggers[0].filter_has_correspondent).toEqual(1) + expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1]) + expect(formValues.triggers[0].filter_has_document_type).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_not_storage_paths).toEqual([1]) + expect(formValues.triggers[0].conditions).toBeUndefined() }) it('should remove selected custom field from the form group', () => { diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 053688df2..d475fec65 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -135,27 +135,116 @@ export const WORKFLOW_ACTION_OPTIONS = [ }, ] -export enum TagConditionType { - Any = 'any', - All = 'all', - None = 'none', +export enum TriggerConditionType { + TagsAny = 'tags_any', + TagsAll = 'tags_all', + TagsNone = 'tags_none', + CorrespondentIs = 'correspondent_is', + CorrespondentNot = 'correspondent_not', + DocumentTypeIs = 'document_type_is', + DocumentTypeNot = 'document_type_not', + StoragePathIs = 'storage_path_is', + StoragePathNot = 'storage_path_not', } -const TAG_CONDITION_OPTIONS = [ +interface TriggerConditionDefinition { + id: TriggerConditionType + name: string + hint?: string + valueLabel: string + inputType: 'tags' | 'select' + allowMultipleEntries: boolean + allowMultipleValues: boolean + selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths' +} + +const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [ { - id: TagConditionType.Any, + id: TriggerConditionType.TagsAny, name: $localize`Has any of these tags`, hint: $localize`Trigger matches when the document has at least one of the selected tags.`, + valueLabel: $localize`Tags`, + inputType: 'tags', + allowMultipleEntries: false, + allowMultipleValues: true, }, { - id: TagConditionType.All, + id: TriggerConditionType.TagsAll, name: $localize`Has all of these tags`, - hint: $localize`Trigger matches when the document has every tag in the selection.`, + hint: $localize`Trigger matches only when every selected tag is present.`, + valueLabel: $localize`Tags`, + inputType: 'tags', + allowMultipleEntries: false, + allowMultipleValues: true, }, { - id: TagConditionType.None, + id: TriggerConditionType.TagsNone, name: $localize`Does not have these tags`, - hint: $localize`Trigger matches when the document has none of the selected tags.`, + hint: $localize`Trigger matches only when none of the selected tags are present.`, + valueLabel: $localize`Tags`, + inputType: 'tags', + allowMultipleEntries: false, + allowMultipleValues: true, + }, + { + id: TriggerConditionType.CorrespondentIs, + name: $localize`Has correspondent`, + hint: $localize`Trigger matches when the document has the selected correspondent.`, + valueLabel: $localize`Correspondent`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: false, + selectItems: 'correspondents', + }, + { + id: TriggerConditionType.CorrespondentNot, + name: $localize`Does not have correspondents`, + hint: $localize`Trigger matches when the document does not have any of the selected correspondents.`, + valueLabel: $localize`Correspondents`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: true, + selectItems: 'correspondents', + }, + { + id: TriggerConditionType.DocumentTypeIs, + name: $localize`Has document type`, + hint: $localize`Trigger matches when the document has the selected document type.`, + valueLabel: $localize`Document type`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: false, + selectItems: 'documentTypes', + }, + { + id: TriggerConditionType.DocumentTypeNot, + name: $localize`Does not have document types`, + hint: $localize`Trigger matches when the document does not have any of the selected document types.`, + valueLabel: $localize`Document types`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: true, + selectItems: 'documentTypes', + }, + { + id: TriggerConditionType.StoragePathIs, + name: $localize`Has storage path`, + hint: $localize`Trigger matches when the document has the selected storage path.`, + valueLabel: $localize`Storage path`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: false, + selectItems: 'storagePaths', + }, + { + id: TriggerConditionType.StoragePathNot, + name: $localize`Does not have storage paths`, + hint: $localize`Trigger matches when the document does not have any of the selected storage paths.`, + valueLabel: $localize`Storage paths`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: true, + selectItems: 'storagePaths', }, ] @@ -194,8 +283,8 @@ export class WorkflowEditDialogComponent { public WorkflowTriggerType = WorkflowTriggerType public WorkflowActionType = WorkflowActionType - public TagConditionType = TagConditionType - public tagConditionOptions = TAG_CONDITION_OPTIONS + public TriggerConditionType = TriggerConditionType + public conditionDefinitions = TRIGGER_CONDITION_DEFINITIONS private correspondentService: CorrespondentService private documentTypeService: DocumentTypeService @@ -423,29 +512,86 @@ export class WorkflowEditDialogComponent formValues.triggers = formValues.triggers.map( (trigger: any, index: number) => { const triggerFormGroup = this.triggerFields.at(index) as FormGroup - const conditions = this.getTagConditionsFormArray(triggerFormGroup) + const conditions = this.getConditionsFormArray(triggerFormGroup) - const tagBuckets: Record = { - [TagConditionType.Any]: [], - [TagConditionType.All]: [], - [TagConditionType.None]: [], + const aggregate = { + filter_has_tags: [] as number[], + filter_has_all_tags: [] as number[], + filter_has_not_tags: [] as number[], + filter_has_not_correspondents: [] as number[], + filter_has_not_document_types: [] as number[], + filter_has_not_storage_paths: [] as number[], + filter_has_correspondent: null as number | null, + filter_has_document_type: null as number | null, + filter_has_storage_path: null as number | null, } conditions.controls.forEach((control) => { - const type = control.get('type').value as TagConditionType - const tags = control.get('tags').value as number[] - if (tags?.length) { - tagBuckets[type] = [...tags] - } else { - tagBuckets[type] = [] + const type = control.get('type').value as TriggerConditionType + const values = control.get('values').value + + if (values === null || values === undefined) { + return + } + + if (Array.isArray(values) && values.length === 0) { + return + } + + switch (type) { + case TriggerConditionType.TagsAny: + aggregate.filter_has_tags = [...values] + break + case TriggerConditionType.TagsAll: + aggregate.filter_has_all_tags = [...values] + break + case TriggerConditionType.TagsNone: + aggregate.filter_has_not_tags = [...values] + break + case TriggerConditionType.CorrespondentIs: + aggregate.filter_has_correspondent = Array.isArray(values) + ? values[0] + : values + break + case TriggerConditionType.CorrespondentNot: + aggregate.filter_has_not_correspondents = [...values] + break + case TriggerConditionType.DocumentTypeIs: + aggregate.filter_has_document_type = Array.isArray(values) + ? values[0] + : values + break + case TriggerConditionType.DocumentTypeNot: + aggregate.filter_has_not_document_types = [...values] + break + case TriggerConditionType.StoragePathIs: + aggregate.filter_has_storage_path = Array.isArray(values) + ? values[0] + : values + break + case TriggerConditionType.StoragePathNot: + aggregate.filter_has_not_storage_paths = [...values] + break } }) - trigger.filter_has_tags = tagBuckets[TagConditionType.Any] - trigger.filter_has_all_tags = tagBuckets[TagConditionType.All] - trigger.filter_has_not_tags = tagBuckets[TagConditionType.None] + trigger.filter_has_tags = aggregate.filter_has_tags + trigger.filter_has_all_tags = aggregate.filter_has_all_tags + trigger.filter_has_not_tags = aggregate.filter_has_not_tags + trigger.filter_has_not_correspondents = + aggregate.filter_has_not_correspondents + trigger.filter_has_not_document_types = + aggregate.filter_has_not_document_types + trigger.filter_has_not_storage_paths = + aggregate.filter_has_not_storage_paths + trigger.filter_has_correspondent = + aggregate.filter_has_correspondent ?? null + trigger.filter_has_document_type = + aggregate.filter_has_document_type ?? null + trigger.filter_has_storage_path = + aggregate.filter_has_storage_path ?? null - delete trigger.tagConditions + delete trigger.conditions return trigger } @@ -455,110 +601,281 @@ export class WorkflowEditDialogComponent return formValues } - private createTagConditionFormGroup( - type: TagConditionType, - tags: number[] = [] + private createConditionFormGroup( + type: TriggerConditionType, + initialValue?: number | number[] ): FormGroup { - return new FormGroup({ + const group = new FormGroup({ type: new FormControl(type), - tags: new FormControl(tags ?? []), + values: new FormControl(this.normalizeConditionValue(type, initialValue)), }) + + group + .get('type') + .valueChanges.subscribe((newType: TriggerConditionType) => { + group.get('values').setValue(this.getDefaultConditionValue(newType), { + emitEvent: false, + }) + }) + + return group } - private buildTagConditionsFormArray(trigger: WorkflowTrigger): FormArray { + private buildConditionFormArray(trigger: WorkflowTrigger): FormArray { const conditions = new FormArray([]) if (trigger.filter_has_tags && trigger.filter_has_tags.length > 0) { conditions.push( - this.createTagConditionFormGroup(TagConditionType.Any, [ - ...trigger.filter_has_tags, - ]) + this.createConditionFormGroup( + TriggerConditionType.TagsAny, + trigger.filter_has_tags + ) ) } if (trigger.filter_has_all_tags && trigger.filter_has_all_tags.length > 0) { conditions.push( - this.createTagConditionFormGroup(TagConditionType.All, [ - ...trigger.filter_has_all_tags, - ]) + this.createConditionFormGroup( + TriggerConditionType.TagsAll, + trigger.filter_has_all_tags + ) ) } if (trigger.filter_has_not_tags && trigger.filter_has_not_tags.length > 0) { conditions.push( - this.createTagConditionFormGroup(TagConditionType.None, [ - ...trigger.filter_has_not_tags, - ]) + this.createConditionFormGroup( + TriggerConditionType.TagsNone, + trigger.filter_has_not_tags + ) + ) + } + + if (trigger.filter_has_correspondent) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.CorrespondentIs, + trigger.filter_has_correspondent + ) + ) + } + + if ( + trigger.filter_has_not_correspondents && + trigger.filter_has_not_correspondents.length > 0 + ) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.CorrespondentNot, + trigger.filter_has_not_correspondents + ) + ) + } + + if (trigger.filter_has_document_type) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.DocumentTypeIs, + trigger.filter_has_document_type + ) + ) + } + + if ( + trigger.filter_has_not_document_types && + trigger.filter_has_not_document_types.length > 0 + ) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.DocumentTypeNot, + trigger.filter_has_not_document_types + ) + ) + } + + if (trigger.filter_has_storage_path) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.StoragePathIs, + trigger.filter_has_storage_path + ) + ) + } + + if ( + trigger.filter_has_not_storage_paths && + trigger.filter_has_not_storage_paths.length > 0 + ) { + conditions.push( + this.createConditionFormGroup( + TriggerConditionType.StoragePathNot, + trigger.filter_has_not_storage_paths + ) ) } return conditions } - getTagConditionsFormArray(formGroup: FormGroup): FormArray { - return formGroup.get('tagConditions') as FormArray + getConditionsFormArray(formGroup: FormGroup): FormArray { + return formGroup.get('conditions') as FormArray } - getTagConditionLabel(type: TagConditionType): string { - return ( - this.tagConditionOptions.find((option) => option.id === type)?.name ?? '' - ) - } + getConditionTypeOptions(formGroup: FormGroup, conditionIndex: number) { + const conditions = this.getConditionsFormArray(formGroup) - getTagConditionHint(formGroup: FormGroup, conditionIndex: number): string { - const conditions = this.getTagConditionsFormArray(formGroup) - const type = conditions.at(conditionIndex).get('type') - .value as TagConditionType - return ( - this.tagConditionOptions.find((option) => option.id === type)?.hint ?? '' - ) - } - - getTagConditionSelectItems(formGroup: FormGroup, conditionIndex: number) { - const conditions = this.getTagConditionsFormArray(formGroup) - return this.tagConditionOptions.map((option) => ({ - ...option, - disabled: conditions.controls.some((control, idx) => { - if (idx === conditionIndex) { - return false - } - return control.get('type').value === option.id - }), + return this.conditionDefinitions.map((definition) => ({ + id: definition.id, + name: definition.name, + disabled: + !definition.allowMultipleEntries && + conditions.controls.some((control, idx) => { + if (idx === conditionIndex) { + return false + } + return control.get('type').value === definition.id + }), })) } - canAddTagCondition(formGroup: FormGroup): boolean { - const conditions = this.getTagConditionsFormArray(formGroup) - return conditions.length < this.tagConditionOptions.length + canAddCondition(formGroup: FormGroup): boolean { + const conditions = this.getConditionsFormArray(formGroup) + const usedTypes = conditions.controls.map( + (control) => control.get('type').value as TriggerConditionType + ) + + return this.conditionDefinitions.some((definition) => { + if (definition.allowMultipleEntries) { + return true + } + return !usedTypes.includes(definition.id) + }) } - addTagCondition(triggerIndex: number) { - const triggerFormGroup = this.triggerFields.at(triggerIndex) as FormGroup - const conditions = this.getTagConditionsFormArray(triggerFormGroup) - const availableTypes = this.tagConditionOptions - .map((option) => option.id) - .filter( - (type) => - !conditions.controls.some( - (control) => control.get('type').value === type - ) - ) - if (availableTypes.length === 0) { + addCondition(triggerFormGroup: FormGroup) { + const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup) + if (triggerIndex === -1) { return } - conditions.push(this.createTagConditionFormGroup(availableTypes[0])) + + const conditions = this.getConditionsFormArray(triggerFormGroup) + + const availableDefinition = this.conditionDefinitions.find((definition) => { + if (definition.allowMultipleEntries) { + return true + } + return !conditions.controls.some( + (control) => control.get('type').value === definition.id + ) + }) + + if (!availableDefinition) { + return + } + + conditions.push(this.createConditionFormGroup(availableDefinition.id)) triggerFormGroup.markAsDirty() triggerFormGroup.markAsTouched() } - removeTagCondition(triggerIndex: number, conditionIndex: number) { - const triggerFormGroup = this.triggerFields.at(triggerIndex) as FormGroup - const conditions = this.getTagConditionsFormArray(triggerFormGroup) + removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) { + const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup) + if (triggerIndex === -1) { + return + } + + const conditions = this.getConditionsFormArray(triggerFormGroup) conditions.removeAt(conditionIndex) triggerFormGroup.markAsDirty() triggerFormGroup.markAsTouched() } + getConditionDefinition( + type: TriggerConditionType + ): TriggerConditionDefinition | undefined { + return this.conditionDefinitions.find( + (definition) => definition.id === type + ) + } + + getConditionName(type: TriggerConditionType): string { + return this.getConditionDefinition(type)?.name ?? '' + } + + getConditionHint(formGroup: FormGroup, conditionIndex: number): string { + const conditions = this.getConditionsFormArray(formGroup) + const type = conditions.at(conditionIndex).get('type') + .value as TriggerConditionType + return this.getConditionDefinition(type)?.hint ?? '' + } + + getConditionValueLabel(type: TriggerConditionType): string { + return this.getConditionDefinition(type)?.valueLabel ?? '' + } + + isTagsCondition(type: TriggerConditionType): boolean { + return this.getConditionDefinition(type)?.inputType === 'tags' + } + + isMultiValueCondition(type: TriggerConditionType): boolean { + switch (type) { + case TriggerConditionType.TagsAny: + case TriggerConditionType.TagsAll: + case TriggerConditionType.TagsNone: + case TriggerConditionType.CorrespondentNot: + case TriggerConditionType.DocumentTypeNot: + case TriggerConditionType.StoragePathNot: + return true + default: + return false + } + } + + isSelectMultiple(type: TriggerConditionType): boolean { + return !this.isTagsCondition(type) && this.isMultiValueCondition(type) + } + + getConditionSelectItems(type: TriggerConditionType) { + const definition = this.getConditionDefinition(type) + if (!definition || definition.inputType !== 'select') { + return [] + } + + switch (definition.selectItems) { + case 'correspondents': + return this.correspondents + case 'documentTypes': + return this.documentTypes + case 'storagePaths': + return this.storagePaths + default: + return [] + } + } + + private getDefaultConditionValue(type: TriggerConditionType) { + return this.isMultiValueCondition(type) ? [] : null + } + + private normalizeConditionValue( + type: TriggerConditionType, + value?: number | number[] + ) { + if (value === undefined || value === null) { + return this.getDefaultConditionValue(type) + } + + if (this.isMultiValueCondition(type)) { + return Array.isArray(value) ? [...value] : [value] + } + + if (Array.isArray(value)) { + return value.length > 0 ? value[0] : null + } + + return value + } + private createTriggerField( trigger: WorkflowTrigger, emitEvent: boolean = false @@ -583,7 +900,7 @@ export class WorkflowEditDialogComponent filter_has_storage_path: new FormControl( trigger.filter_has_storage_path ), - tagConditions: this.buildTagConditionsFormArray(trigger), + conditions: this.buildConditionFormArray(trigger), schedule_offset_days: new FormControl(trigger.schedule_offset_days), schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), schedule_recurring_interval_days: new FormControl( @@ -708,6 +1025,9 @@ export class WorkflowEditDialogComponent filter_has_tags: [], filter_has_all_tags: [], filter_has_not_tags: [], + filter_has_not_correspondents: [], + filter_has_not_document_types: [], + filter_has_not_storage_paths: [], filter_has_correspondent: null, filter_has_document_type: null, filter_has_storage_path: null, diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index 4bad6f904..d2f0d90b9 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -44,6 +44,12 @@ export interface WorkflowTrigger extends ObjectWithId { filter_has_not_tags?: number[] // Tag.id[] + filter_has_not_correspondents?: number[] // Correspondent.id[] + + filter_has_not_document_types?: number[] // DocumentType.id[] + + filter_has_not_storage_paths?: number[] // StoragePath.id[] + filter_has_correspondent?: number // Correspondent.id filter_has_document_type?: number // DocumentType.id diff --git a/src/documents/matching.py b/src/documents/matching.py index 6b1ed09bc..7e0322621 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -404,34 +404,76 @@ def existing_document_matches_workflow( trigger_matched = False # Document correspondent vs trigger has_correspondent - if ( - trigger.filter_has_correspondent is not None - and document.correspondent != trigger.filter_has_correspondent - ): - reason = ( - f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}", - ) - trigger_matched = False + if trigger_matched: + if ( + trigger.filter_has_correspondent is not None + and document.correspondent != trigger.filter_has_correspondent + ): + reason = ( + f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}", + ) + trigger_matched = False + + if ( + trigger.filter_has_not_correspondents.all().count() > 0 + and document.correspondent + and trigger.filter_has_not_correspondents.filter( + id=document.correspondent_id, + ).exists() + ): + reason = ( + f"Document correspondent {document.correspondent} is excluded by" + f" {trigger.filter_has_not_correspondents.all()}", + ) + trigger_matched = False # Document document_type vs trigger has_document_type - if ( - trigger.filter_has_document_type is not None - and document.document_type != trigger.filter_has_document_type - ): - reason = ( - f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}", - ) - trigger_matched = False + if trigger_matched: + if ( + trigger.filter_has_document_type is not None + and document.document_type != trigger.filter_has_document_type + ): + reason = ( + f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}", + ) + trigger_matched = False + + if ( + trigger.filter_has_not_document_types.all().count() > 0 + and document.document_type + and trigger.filter_has_not_document_types.filter( + id=document.document_type_id, + ).exists() + ): + reason = ( + f"Document doc type {document.document_type} is excluded by" + f" {trigger.filter_has_not_document_types.all()}", + ) + trigger_matched = False # Document storage_path vs trigger has_storage_path - if ( - trigger.filter_has_storage_path is not None - and document.storage_path != trigger.filter_has_storage_path - ): - reason = ( - f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}", - ) - trigger_matched = False + if trigger_matched: + if ( + trigger.filter_has_storage_path is not None + and document.storage_path != trigger.filter_has_storage_path + ): + reason = ( + f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}", + ) + trigger_matched = False + + if ( + trigger.filter_has_not_storage_paths.all().count() > 0 + and document.storage_path + and trigger.filter_has_not_storage_paths.filter( + id=document.storage_path_id, + ).exists() + ): + reason = ( + f"Document storage path {document.storage_path} is excluded by" + f" {trigger.filter_has_not_storage_paths.all()}", + ) + trigger_matched = False # Document original_filename vs trigger filename if ( @@ -482,16 +524,31 @@ def prefilter_documents_by_workflowtrigger( correspondent=trigger.filter_has_correspondent, ) + if trigger.filter_has_not_correspondents.all().count() > 0: + documents = documents.exclude( + correspondent__in=trigger.filter_has_not_correspondents.all(), + ) + if trigger.filter_has_document_type is not None: documents = documents.filter( document_type=trigger.filter_has_document_type, ) + if trigger.filter_has_not_document_types.all().count() > 0: + documents = documents.exclude( + document_type__in=trigger.filter_has_not_document_types.all(), + ) + if trigger.filter_has_storage_path is not None: documents = documents.filter( storage_path=trigger.filter_has_storage_path, ) + if trigger.filter_has_not_storage_paths.all().count() > 0: + documents = documents.exclude( + storage_path__in=trigger.filter_has_not_storage_paths.all(), + ) + if trigger.filter_filename is not None and len(trigger.filter_filename) > 0: # the true fnmatch will actually run later so we just want a loose filter here regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$") diff --git a/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py b/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py index 4c9c1513c..c042035d2 100644 --- a/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py +++ b/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-10-07 16:09 +# Generated by Django 5.2.6 on 2025-10-07 16:22 from django.db import migrations from django.db import models @@ -20,6 +20,36 @@ class Migration(migrations.Migration): verbose_name="has all of these tag(s)", ), ), + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_not_correspondents", + field=models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not_correspondent", + to="documents.correspondent", + verbose_name="does not have these correspondent(s)", + ), + ), + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_not_document_types", + field=models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not_document_type", + to="documents.documenttype", + verbose_name="does not have these document type(s)", + ), + ), + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_not_storage_paths", + field=models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not_storage_path", + to="documents.storagepath", + verbose_name="does not have these storage path(s)", + ), + ), migrations.AddField( model_name="workflowtrigger", name="filter_has_not_tags", diff --git a/src/documents/models.py b/src/documents/models.py index bff120efc..28b9e1be2 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1087,6 +1087,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("has this document type"), ) + filter_has_not_document_types = models.ManyToManyField( + DocumentType, + blank=True, + related_name="workflowtriggers_has_not_document_type", + verbose_name=_("does not have these document type(s)"), + ) + filter_has_correspondent = models.ForeignKey( Correspondent, null=True, @@ -1095,6 +1102,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("has this correspondent"), ) + filter_has_not_correspondents = models.ManyToManyField( + Correspondent, + blank=True, + related_name="workflowtriggers_has_not_correspondent", + verbose_name=_("does not have these correspondent(s)"), + ) + filter_has_storage_path = models.ForeignKey( StoragePath, null=True, @@ -1103,6 +1117,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("has this storage path"), ) + filter_has_not_storage_paths = models.ManyToManyField( + StoragePath, + blank=True, + related_name="workflowtriggers_has_not_storage_path", + verbose_name=_("does not have these storage path(s)"), + ) + schedule_offset_days = models.IntegerField( _("schedule offset days"), default=0, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index b1d09bf96..6fb09c718 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2196,6 +2196,9 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): "filter_has_tags", "filter_has_all_tags", "filter_has_not_tags", + "filter_has_not_correspondents", + "filter_has_not_document_types", + "filter_has_not_storage_paths", "filter_has_correspondent", "filter_has_document_type", "filter_has_storage_path", @@ -2418,6 +2421,18 @@ class WorkflowSerializer(serializers.ModelSerializer): filter_has_tags = trigger.pop("filter_has_tags", None) filter_has_all_tags = trigger.pop("filter_has_all_tags", None) filter_has_not_tags = trigger.pop("filter_has_not_tags", None) + filter_has_not_correspondents = trigger.pop( + "filter_has_not_correspondents", + None, + ) + filter_has_not_document_types = trigger.pop( + "filter_has_not_document_types", + None, + ) + filter_has_not_storage_paths = trigger.pop( + "filter_has_not_storage_paths", + None, + ) # Convert sources to strings to handle django-multiselectfield v1.0 changes WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger) trigger_instance, _ = WorkflowTrigger.objects.update_or_create( @@ -2430,6 +2445,18 @@ class WorkflowSerializer(serializers.ModelSerializer): trigger_instance.filter_has_all_tags.set(filter_has_all_tags) if filter_has_not_tags is not None: trigger_instance.filter_has_not_tags.set(filter_has_not_tags) + if filter_has_not_correspondents is not None: + trigger_instance.filter_has_not_correspondents.set( + filter_has_not_correspondents, + ) + if filter_has_not_document_types is not None: + trigger_instance.filter_has_not_document_types.set( + filter_has_not_document_types, + ) + if filter_has_not_storage_paths is not None: + trigger_instance.filter_has_not_storage_paths.set( + filter_has_not_storage_paths, + ) set_triggers.append(trigger_instance) if actions is not None and actions is not serializers.empty: diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index ace7782ee..1e3dc6f09 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -186,6 +186,9 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_tags": [self.t1.id], "filter_has_all_tags": [self.t2.id], "filter_has_not_tags": [self.t3.id], + "filter_has_not_correspondents": [self.c2.id], + "filter_has_not_document_types": [self.dt2.id], + "filter_has_not_storage_paths": [self.sp2.id], "filter_has_document_type": self.dt.id, "filter_has_correspondent": self.c.id, "filter_has_storage_path": self.sp.id, @@ -239,6 +242,18 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): set(trigger.filter_has_not_tags.values_list("id", flat=True)), {self.t3.id}, ) + self.assertSetEqual( + set(trigger.filter_has_not_correspondents.values_list("id", flat=True)), + {self.c2.id}, + ) + self.assertSetEqual( + set(trigger.filter_has_not_document_types.values_list("id", flat=True)), + {self.dt2.id}, + ) + self.assertSetEqual( + set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)), + {self.sp2.id}, + ) def test_api_create_invalid_workflow_trigger(self): """ @@ -394,6 +409,9 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_tags": [self.t1.id], "filter_has_all_tags": [self.t2.id], "filter_has_not_tags": [self.t3.id], + "filter_has_not_correspondents": [self.c2.id], + "filter_has_not_document_types": [self.dt2.id], + "filter_has_not_storage_paths": [self.sp2.id], "filter_has_correspondent": self.c.id, "filter_has_document_type": self.dt.id, }, @@ -419,6 +437,18 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): workflow.triggers.first().filter_has_not_tags.first(), self.t3, ) + self.assertEqual( + workflow.triggers.first().filter_has_not_correspondents.first(), + self.c2, + ) + self.assertEqual( + workflow.triggers.first().filter_has_not_document_types.first(), + self.dt2, + ) + self.assertEqual( + workflow.triggers.first().filter_has_not_storage_paths.first(), + self.sp2, + ) self.assertEqual(workflow.actions.first().assign_title, "Action New Title") def test_api_update_workflow_no_trigger_actions(self): diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 0ed041421..607ba47cc 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -1159,6 +1159,114 @@ class TestWorkflows( ) self.assertIn(expected_str, cm.output[1]) + def test_document_added_excluded_correspondent(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + trigger.filter_has_not_correspondents.set([self.c]) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + + 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 {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = ( + f"Document correspondent {doc.correspondent} is excluded by" + f" {trigger.filter_has_not_correspondents.all()}" + ) + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_excluded_document_types(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + trigger.filter_has_not_document_types.set([self.dt]) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + document_type=self.dt, + original_filename="sample.pdf", + ) + + 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 {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = ( + f"Document doc type {doc.document_type} is excluded by" + f" {trigger.filter_has_not_document_types.all()}" + ) + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_excluded_storage_paths(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + trigger.filter_has_not_storage_paths.set([self.sp]) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + storage_path=self.sp, + original_filename="sample.pdf", + ) + + 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 {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = ( + f"Document storage path {doc.storage_path} is excluded by" + f" {trigger.filter_has_not_storage_paths.all()}" + ) + self.assertIn(expected_str, cm.output[1]) + def test_document_added_no_match_doctype(self): trigger = WorkflowTrigger.objects.create( type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,