Add negation for other things, universal query builder

This commit is contained in:
shamoon
2025-10-07 09:42:00 -07:00
parent 1fed785c7d
commit 4965480958
10 changed files with 797 additions and 143 deletions

View File

@@ -172,32 +172,34 @@
} }
} }
</div> </div>
</div>
<div class="row">
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<div class="col-md-6"> <div class="col">
<div class="trigger-tag-conditions mb-3"> <div class="trigger-conditions mb-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<label class="form-label mb-0" i18n>Tag conditions</label> <label class="form-label mb-0" i18n>Conditions</label>
<button <button
type="button" type="button"
class="btn btn-sm btn-outline-primary ms-auto" class="btn btn-sm btn-outline-primary ms-auto"
(click)="addTagCondition(i)" (click)="addCondition(formGroup)"
[disabled]="!canAddTagCondition(formGroup)" [disabled]="!canAddCondition(formGroup)"
> >
<i-bs name="plus-circle"></i-bs>&nbsp;<span i18n>Add condition</span> <i-bs name="plus-circle"></i-bs>&nbsp;<span i18n>Add condition</span>
</button> </button>
</div> </div>
<div class="mt-2" formArrayName="tagConditions"> <div class="mt-2" formArrayName="conditions">
@if (getTagConditionsFormArray(formGroup).length === 0) { @if (getConditionsFormArray(formGroup).length === 0) {
<p class="text-muted small" i18n>No tag conditions added. Add one to refine tag-based matching.</p> <p class="text-muted small" i18n>No conditions added. Add one to define document filters.</p>
} }
@for (condition of getTagConditionsFormArray(formGroup).controls; track condition; let conditionIndex = $index) { @for (condition of getConditionsFormArray(formGroup).controls; track condition; let conditionIndex = $index) {
<div [formGroupName]="conditionIndex" class="border rounded p-3 mb-2"> <div [formGroupName]="conditionIndex" class="border rounded p-3 mb-2">
<div class="d-flex align-items-start gap-2 mb-2"> <div class="d-flex align-items-start gap-2 mb-2">
<div class="flex-grow-1"> <div class="flex-grow-1">
<pngx-input-select <pngx-input-select
i18n-title i18n-title
title="Condition type" title="Condition type"
[items]="getTagConditionSelectItems(formGroup, conditionIndex)" [items]="getConditionTypeOptions(formGroup, conditionIndex)"
formControlName="type" formControlName="type"
[allowNull]="false" [allowNull]="false"
></pngx-input-select> ></pngx-input-select>
@@ -205,25 +207,33 @@
<button <button
type="button" type="button"
class="btn btn-link text-danger p-0 ms-1" class="btn btn-link text-danger p-0 ms-1"
(click)="removeTagCondition(i, conditionIndex)" (click)="removeCondition(formGroup, conditionIndex)"
> >
<i-bs name="trash"></i-bs> <i-bs name="trash"></i-bs>
<span class="visually-hidden" i18n>Remove condition</span> <span class="visually-hidden" i18n>Remove condition</span>
</button> </button>
</div> </div>
<pngx-input-tags @if (isTagsCondition(condition.get('type').value)) {
[allowCreate]="false" <pngx-input-tags
[title]="getTagConditionLabel(condition.get('type').value)" [allowCreate]="false"
[hint]="getTagConditionHint(formGroup, conditionIndex)" [title]="getConditionValueLabel(condition.get('type').value)"
formControlName="tags" [hint]="getConditionHint(formGroup, conditionIndex)"
></pngx-input-tags> formControlName="values"
></pngx-input-tags>
} @else {
<pngx-input-select
[title]="getConditionValueLabel(condition.get('type').value)"
[items]="getConditionSelectItems(condition.get('type').value)"
[hint]="getConditionHint(formGroup, conditionIndex)"
[allowNull]="true"
[multiple]="isSelectMultiple(condition.get('type').value)"
formControlName="values"
></pngx-input-select>
}
</div> </div>
} }
</div> </div>
</div> </div>
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
</div> </div>
} }
</div> </div>

View File

@@ -43,6 +43,7 @@ import { EditDialogMode } from '../edit-dialog.component'
import { import {
DOCUMENT_SOURCE_OPTIONS, DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS, SCHEDULE_DATE_FIELD_OPTIONS,
TriggerConditionType,
WORKFLOW_ACTION_OPTIONS, WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS, WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent, WorkflowEditDialogComponent,
@@ -375,29 +376,73 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.objectForm.get('actions').value[0].webhook).toBeNull() 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.object = undefined
component.addTrigger() component.addTrigger()
const triggerGroup = component.triggerFields.at(0) const triggerGroup = component.triggerFields.at(0)
component.addTagCondition(0) component.addCondition(triggerGroup as FormGroup)
component.addTagCondition(0) component.addCondition(triggerGroup as FormGroup)
component.addTagCondition(0) component.addCondition(triggerGroup as FormGroup)
const tagConditions = component.getTagConditionsFormArray( const conditions = component.getConditionsFormArray(
triggerGroup as FormGroup triggerGroup as FormGroup
) )
expect(tagConditions.length).toBe(3) expect(conditions.length).toBe(3)
tagConditions.at(0).get('tags').setValue([1]) conditions.at(0).get('values').setValue([1])
tagConditions.at(1).get('tags').setValue([2, 3]) conditions.at(1).get('values').setValue([2, 3])
tagConditions.at(2).get('tags').setValue([4]) 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']() const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([1]) 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_all_tags).toEqual([2, 3])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4]) 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', () => { it('should remove selected custom field from the form group', () => {

View File

@@ -135,27 +135,116 @@ export const WORKFLOW_ACTION_OPTIONS = [
}, },
] ]
export enum TagConditionType { export enum TriggerConditionType {
Any = 'any', TagsAny = 'tags_any',
All = 'all', TagsAll = 'tags_all',
None = 'none', 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`, name: $localize`Has any of these tags`,
hint: $localize`Trigger matches when the document has at least one of the selected 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`, 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`, 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 WorkflowTriggerType = WorkflowTriggerType
public WorkflowActionType = WorkflowActionType public WorkflowActionType = WorkflowActionType
public TagConditionType = TagConditionType public TriggerConditionType = TriggerConditionType
public tagConditionOptions = TAG_CONDITION_OPTIONS public conditionDefinitions = TRIGGER_CONDITION_DEFINITIONS
private correspondentService: CorrespondentService private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService private documentTypeService: DocumentTypeService
@@ -423,29 +512,86 @@ export class WorkflowEditDialogComponent
formValues.triggers = formValues.triggers.map( formValues.triggers = formValues.triggers.map(
(trigger: any, index: number) => { (trigger: any, index: number) => {
const triggerFormGroup = this.triggerFields.at(index) as FormGroup const triggerFormGroup = this.triggerFields.at(index) as FormGroup
const conditions = this.getTagConditionsFormArray(triggerFormGroup) const conditions = this.getConditionsFormArray(triggerFormGroup)
const tagBuckets: Record<TagConditionType, number[]> = { const aggregate = {
[TagConditionType.Any]: [], filter_has_tags: [] as number[],
[TagConditionType.All]: [], filter_has_all_tags: [] as number[],
[TagConditionType.None]: [], 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) => { conditions.controls.forEach((control) => {
const type = control.get('type').value as TagConditionType const type = control.get('type').value as TriggerConditionType
const tags = control.get('tags').value as number[] const values = control.get('values').value
if (tags?.length) {
tagBuckets[type] = [...tags] if (values === null || values === undefined) {
} else { return
tagBuckets[type] = [] }
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_tags = aggregate.filter_has_tags
trigger.filter_has_all_tags = tagBuckets[TagConditionType.All] trigger.filter_has_all_tags = aggregate.filter_has_all_tags
trigger.filter_has_not_tags = tagBuckets[TagConditionType.None] 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 return trigger
} }
@@ -455,110 +601,281 @@ export class WorkflowEditDialogComponent
return formValues return formValues
} }
private createTagConditionFormGroup( private createConditionFormGroup(
type: TagConditionType, type: TriggerConditionType,
tags: number[] = [] initialValue?: number | number[]
): FormGroup { ): FormGroup {
return new FormGroup({ const group = new FormGroup({
type: new FormControl(type), 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([]) const conditions = new FormArray([])
if (trigger.filter_has_tags && trigger.filter_has_tags.length > 0) { if (trigger.filter_has_tags && trigger.filter_has_tags.length > 0) {
conditions.push( conditions.push(
this.createTagConditionFormGroup(TagConditionType.Any, [ this.createConditionFormGroup(
...trigger.filter_has_tags, TriggerConditionType.TagsAny,
]) trigger.filter_has_tags
)
) )
} }
if (trigger.filter_has_all_tags && trigger.filter_has_all_tags.length > 0) { if (trigger.filter_has_all_tags && trigger.filter_has_all_tags.length > 0) {
conditions.push( conditions.push(
this.createTagConditionFormGroup(TagConditionType.All, [ this.createConditionFormGroup(
...trigger.filter_has_all_tags, TriggerConditionType.TagsAll,
]) trigger.filter_has_all_tags
)
) )
} }
if (trigger.filter_has_not_tags && trigger.filter_has_not_tags.length > 0) { if (trigger.filter_has_not_tags && trigger.filter_has_not_tags.length > 0) {
conditions.push( conditions.push(
this.createTagConditionFormGroup(TagConditionType.None, [ this.createConditionFormGroup(
...trigger.filter_has_not_tags, 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 return conditions
} }
getTagConditionsFormArray(formGroup: FormGroup): FormArray { getConditionsFormArray(formGroup: FormGroup): FormArray {
return formGroup.get('tagConditions') as FormArray return formGroup.get('conditions') as FormArray
} }
getTagConditionLabel(type: TagConditionType): string { getConditionTypeOptions(formGroup: FormGroup, conditionIndex: number) {
return ( const conditions = this.getConditionsFormArray(formGroup)
this.tagConditionOptions.find((option) => option.id === type)?.name ?? ''
)
}
getTagConditionHint(formGroup: FormGroup, conditionIndex: number): string { return this.conditionDefinitions.map((definition) => ({
const conditions = this.getTagConditionsFormArray(formGroup) id: definition.id,
const type = conditions.at(conditionIndex).get('type') name: definition.name,
.value as TagConditionType disabled:
return ( !definition.allowMultipleEntries &&
this.tagConditionOptions.find((option) => option.id === type)?.hint ?? '' conditions.controls.some((control, idx) => {
) if (idx === conditionIndex) {
} return false
}
getTagConditionSelectItems(formGroup: FormGroup, conditionIndex: number) { return control.get('type').value === definition.id
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
}),
})) }))
} }
canAddTagCondition(formGroup: FormGroup): boolean { canAddCondition(formGroup: FormGroup): boolean {
const conditions = this.getTagConditionsFormArray(formGroup) const conditions = this.getConditionsFormArray(formGroup)
return conditions.length < this.tagConditionOptions.length 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) { addCondition(triggerFormGroup: FormGroup) {
const triggerFormGroup = this.triggerFields.at(triggerIndex) as FormGroup const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
const conditions = this.getTagConditionsFormArray(triggerFormGroup) if (triggerIndex === -1) {
const availableTypes = this.tagConditionOptions
.map((option) => option.id)
.filter(
(type) =>
!conditions.controls.some(
(control) => control.get('type').value === type
)
)
if (availableTypes.length === 0) {
return 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.markAsDirty()
triggerFormGroup.markAsTouched() triggerFormGroup.markAsTouched()
} }
removeTagCondition(triggerIndex: number, conditionIndex: number) { removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) {
const triggerFormGroup = this.triggerFields.at(triggerIndex) as FormGroup const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
const conditions = this.getTagConditionsFormArray(triggerFormGroup) if (triggerIndex === -1) {
return
}
const conditions = this.getConditionsFormArray(triggerFormGroup)
conditions.removeAt(conditionIndex) conditions.removeAt(conditionIndex)
triggerFormGroup.markAsDirty() triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched() 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( private createTriggerField(
trigger: WorkflowTrigger, trigger: WorkflowTrigger,
emitEvent: boolean = false emitEvent: boolean = false
@@ -583,7 +900,7 @@ export class WorkflowEditDialogComponent
filter_has_storage_path: new FormControl( filter_has_storage_path: new FormControl(
trigger.filter_has_storage_path trigger.filter_has_storage_path
), ),
tagConditions: this.buildTagConditionsFormArray(trigger), conditions: this.buildConditionFormArray(trigger),
schedule_offset_days: new FormControl(trigger.schedule_offset_days), schedule_offset_days: new FormControl(trigger.schedule_offset_days),
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
schedule_recurring_interval_days: new FormControl( schedule_recurring_interval_days: new FormControl(
@@ -708,6 +1025,9 @@ export class WorkflowEditDialogComponent
filter_has_tags: [], filter_has_tags: [],
filter_has_all_tags: [], filter_has_all_tags: [],
filter_has_not_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_correspondent: null,
filter_has_document_type: null, filter_has_document_type: null,
filter_has_storage_path: null, filter_has_storage_path: null,

View File

@@ -44,6 +44,12 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_not_tags?: number[] // Tag.id[] 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_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id filter_has_document_type?: number // DocumentType.id

View File

@@ -404,34 +404,76 @@ def existing_document_matches_workflow(
trigger_matched = False trigger_matched = False
# Document correspondent vs trigger has_correspondent # Document correspondent vs trigger has_correspondent
if ( if trigger_matched:
trigger.filter_has_correspondent is not None if (
and document.correspondent != trigger.filter_has_correspondent 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}", reason = (
) f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}",
trigger_matched = False )
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 # Document document_type vs trigger has_document_type
if ( if trigger_matched:
trigger.filter_has_document_type is not None if (
and document.document_type != trigger.filter_has_document_type 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}", reason = (
) f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}",
trigger_matched = False )
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 # Document storage_path vs trigger has_storage_path
if ( if trigger_matched:
trigger.filter_has_storage_path is not None if (
and document.storage_path != trigger.filter_has_storage_path 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}", reason = (
) f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}",
trigger_matched = False )
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 # Document original_filename vs trigger filename
if ( if (
@@ -482,16 +524,31 @@ def prefilter_documents_by_workflowtrigger(
correspondent=trigger.filter_has_correspondent, 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: if trigger.filter_has_document_type is not None:
documents = documents.filter( documents = documents.filter(
document_type=trigger.filter_has_document_type, 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: if trigger.filter_has_storage_path is not None:
documents = documents.filter( documents = documents.filter(
storage_path=trigger.filter_has_storage_path, 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: if trigger.filter_filename is not None and len(trigger.filter_filename) > 0:
# the true fnmatch will actually run later so we just want a loose filter here # the true fnmatch will actually run later so we just want a loose filter here
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$") regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.6 on 2025-10-07 16:09 # Generated by Django 5.2.6 on 2025-10-07 16:22
from django.db import migrations from django.db import migrations
from django.db import models from django.db import models
@@ -20,6 +20,36 @@ class Migration(migrations.Migration):
verbose_name="has all of these tag(s)", 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( migrations.AddField(
model_name="workflowtrigger", model_name="workflowtrigger",
name="filter_has_not_tags", name="filter_has_not_tags",

View File

@@ -1087,6 +1087,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this document type"), 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( filter_has_correspondent = models.ForeignKey(
Correspondent, Correspondent,
null=True, null=True,
@@ -1095,6 +1102,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this correspondent"), 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( filter_has_storage_path = models.ForeignKey(
StoragePath, StoragePath,
null=True, null=True,
@@ -1103,6 +1117,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this storage path"), 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 = models.IntegerField(
_("schedule offset days"), _("schedule offset days"),
default=0, default=0,

View File

@@ -2196,6 +2196,9 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"filter_has_tags", "filter_has_tags",
"filter_has_all_tags", "filter_has_all_tags",
"filter_has_not_tags", "filter_has_not_tags",
"filter_has_not_correspondents",
"filter_has_not_document_types",
"filter_has_not_storage_paths",
"filter_has_correspondent", "filter_has_correspondent",
"filter_has_document_type", "filter_has_document_type",
"filter_has_storage_path", "filter_has_storage_path",
@@ -2418,6 +2421,18 @@ class WorkflowSerializer(serializers.ModelSerializer):
filter_has_tags = trigger.pop("filter_has_tags", None) filter_has_tags = trigger.pop("filter_has_tags", None)
filter_has_all_tags = trigger.pop("filter_has_all_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_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 # Convert sources to strings to handle django-multiselectfield v1.0 changes
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger) WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
trigger_instance, _ = WorkflowTrigger.objects.update_or_create( 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) trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
if filter_has_not_tags is not None: if filter_has_not_tags is not None:
trigger_instance.filter_has_not_tags.set(filter_has_not_tags) 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) set_triggers.append(trigger_instance)
if actions is not None and actions is not serializers.empty: if actions is not None and actions is not serializers.empty:

View File

@@ -186,6 +186,9 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_tags": [self.t1.id], "filter_has_tags": [self.t1.id],
"filter_has_all_tags": [self.t2.id], "filter_has_all_tags": [self.t2.id],
"filter_has_not_tags": [self.t3.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_document_type": self.dt.id,
"filter_has_correspondent": self.c.id, "filter_has_correspondent": self.c.id,
"filter_has_storage_path": self.sp.id, "filter_has_storage_path": self.sp.id,
@@ -239,6 +242,18 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
set(trigger.filter_has_not_tags.values_list("id", flat=True)), set(trigger.filter_has_not_tags.values_list("id", flat=True)),
{self.t3.id}, {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): def test_api_create_invalid_workflow_trigger(self):
""" """
@@ -394,6 +409,9 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_tags": [self.t1.id], "filter_has_tags": [self.t1.id],
"filter_has_all_tags": [self.t2.id], "filter_has_all_tags": [self.t2.id],
"filter_has_not_tags": [self.t3.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_correspondent": self.c.id,
"filter_has_document_type": self.dt.id, "filter_has_document_type": self.dt.id,
}, },
@@ -419,6 +437,18 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
workflow.triggers.first().filter_has_not_tags.first(), workflow.triggers.first().filter_has_not_tags.first(),
self.t3, 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") self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
def test_api_update_workflow_no_trigger_actions(self): def test_api_update_workflow_no_trigger_actions(self):

View File

@@ -1159,6 +1159,114 @@ class TestWorkflows(
) )
self.assertIn(expected_str, cm.output[1]) 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): def test_document_added_no_match_doctype(self):
trigger = WorkflowTrigger.objects.create( trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,