+
-
+
-
- @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,