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 class="row">
@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="trigger-tag-conditions mb-3">
<div class="col">
<div class="trigger-conditions mb-3">
<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
type="button"
class="btn btn-sm btn-outline-primary ms-auto"
(click)="addTagCondition(i)"
[disabled]="!canAddTagCondition(formGroup)"
(click)="addCondition(formGroup)"
[disabled]="!canAddCondition(formGroup)"
>
<i-bs name="plus-circle"></i-bs>&nbsp;<span i18n>Add condition</span>
</button>
</div>
<div class="mt-2" formArrayName="tagConditions">
@if (getTagConditionsFormArray(formGroup).length === 0) {
<p class="text-muted small" i18n>No tag conditions added. Add one to refine tag-based matching.</p>
<div class="mt-2" formArrayName="conditions">
@if (getConditionsFormArray(formGroup).length === 0) {
<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 class="d-flex align-items-start gap-2 mb-2">
<div class="flex-grow-1">
<pngx-input-select
i18n-title
title="Condition type"
[items]="getTagConditionSelectItems(formGroup, conditionIndex)"
[items]="getConditionTypeOptions(formGroup, conditionIndex)"
formControlName="type"
[allowNull]="false"
></pngx-input-select>
@@ -205,25 +207,33 @@
<button
type="button"
class="btn btn-link text-danger p-0 ms-1"
(click)="removeTagCondition(i, conditionIndex)"
(click)="removeCondition(formGroup, conditionIndex)"
>
<i-bs name="trash"></i-bs>
<span class="visually-hidden" i18n>Remove condition</span>
</button>
</div>
<pngx-input-tags
[allowCreate]="false"
[title]="getTagConditionLabel(condition.get('type').value)"
[hint]="getTagConditionHint(formGroup, conditionIndex)"
formControlName="tags"
></pngx-input-tags>
@if (isTagsCondition(condition.get('type').value)) {
<pngx-input-tags
[allowCreate]="false"
[title]="getConditionValueLabel(condition.get('type').value)"
[hint]="getConditionHint(formGroup, conditionIndex)"
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>
<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>

View File

@@ -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', () => {

View File

@@ -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, number[]> = {
[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,

View File

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

View File

@@ -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("$")

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 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",

View File

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

View File

@@ -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:

View File

@@ -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):

View File

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