mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-12 02:26:09 -05:00
Initial crack
This commit is contained in:
@@ -174,7 +174,53 @@
|
|||||||
</div>
|
</div>
|
||||||
@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-md-6">
|
||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
<div class="trigger-tag-conditions mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label class="form-label mb-0" i18n>Tag conditions</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary ms-auto"
|
||||||
|
(click)="addTagCondition(i)"
|
||||||
|
[disabled]="!canAddTagCondition(formGroup)"
|
||||||
|
>
|
||||||
|
<i-bs name="plus-circle"></i-bs> <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>
|
||||||
|
}
|
||||||
|
@for (condition of getTagConditionsFormArray(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)"
|
||||||
|
formControlName="type"
|
||||||
|
[allowNull]="false"
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link text-danger p-0 ms-1"
|
||||||
|
(click)="removeTagCondition(i, 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>
|
||||||
|
</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 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 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>
|
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
|
||||||
|
@@ -375,6 +375,31 @@ 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', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0)
|
||||||
|
component.addTagCondition(0)
|
||||||
|
component.addTagCondition(0)
|
||||||
|
component.addTagCondition(0)
|
||||||
|
|
||||||
|
const tagConditions = component.getTagConditionsFormArray(
|
||||||
|
triggerGroup as FormGroup
|
||||||
|
)
|
||||||
|
expect(tagConditions.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])
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
it('should remove selected custom field from the form group', () => {
|
it('should remove selected custom field from the form group', () => {
|
||||||
const formGroup = new FormGroup({
|
const formGroup = new FormGroup({
|
||||||
assign_custom_fields: new FormControl([1, 2, 3]),
|
assign_custom_fields: new FormControl([1, 2, 3]),
|
||||||
|
@@ -135,6 +135,30 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export enum TagConditionType {
|
||||||
|
Any = 'any',
|
||||||
|
All = 'all',
|
||||||
|
None = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_CONDITION_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: TagConditionType.Any,
|
||||||
|
name: $localize`Has any of these tags`,
|
||||||
|
hint: $localize`Trigger matches when the document has at least one of the selected tags.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TagConditionType.All,
|
||||||
|
name: $localize`Has all of these tags`,
|
||||||
|
hint: $localize`Trigger matches when the document has every tag in the selection.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TagConditionType.None,
|
||||||
|
name: $localize`Does not have these tags`,
|
||||||
|
hint: $localize`Trigger matches when the document has none of the selected tags.`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||||
(a) => a.id !== MATCH_AUTO
|
(a) => a.id !== MATCH_AUTO
|
||||||
)
|
)
|
||||||
@@ -170,6 +194,8 @@ export class WorkflowEditDialogComponent
|
|||||||
{
|
{
|
||||||
public WorkflowTriggerType = WorkflowTriggerType
|
public WorkflowTriggerType = WorkflowTriggerType
|
||||||
public WorkflowActionType = WorkflowActionType
|
public WorkflowActionType = WorkflowActionType
|
||||||
|
public TagConditionType = TagConditionType
|
||||||
|
public tagConditionOptions = TAG_CONDITION_OPTIONS
|
||||||
|
|
||||||
private correspondentService: CorrespondentService
|
private correspondentService: CorrespondentService
|
||||||
private documentTypeService: DocumentTypeService
|
private documentTypeService: DocumentTypeService
|
||||||
@@ -390,6 +416,149 @@ export class WorkflowEditDialogComponent
|
|||||||
return this.objectForm.get('actions') as FormArray
|
return this.objectForm.get('actions') as FormArray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override getFormValues(): any {
|
||||||
|
const formValues = super.getFormValues()
|
||||||
|
|
||||||
|
if (formValues?.triggers?.length) {
|
||||||
|
formValues.triggers = formValues.triggers.map(
|
||||||
|
(trigger: any, index: number) => {
|
||||||
|
const triggerFormGroup = this.triggerFields.at(index) as FormGroup
|
||||||
|
const conditions = this.getTagConditionsFormArray(triggerFormGroup)
|
||||||
|
|
||||||
|
const tagBuckets: Record<TagConditionType, number[]> = {
|
||||||
|
[TagConditionType.Any]: [],
|
||||||
|
[TagConditionType.All]: [],
|
||||||
|
[TagConditionType.None]: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
trigger.filter_has_tags = tagBuckets[TagConditionType.Any]
|
||||||
|
trigger.filter_has_all_tags = tagBuckets[TagConditionType.All]
|
||||||
|
trigger.filter_has_not_tags = tagBuckets[TagConditionType.None]
|
||||||
|
|
||||||
|
delete trigger.tagConditions
|
||||||
|
|
||||||
|
return trigger
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formValues
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTagConditionFormGroup(
|
||||||
|
type: TagConditionType,
|
||||||
|
tags: number[] = []
|
||||||
|
): FormGroup {
|
||||||
|
return new FormGroup({
|
||||||
|
type: new FormControl(type),
|
||||||
|
tags: new FormControl(tags ?? []),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTagConditionsFormArray(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,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.filter_has_all_tags && trigger.filter_has_all_tags.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
this.createTagConditionFormGroup(TagConditionType.All, [
|
||||||
|
...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,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
}
|
||||||
|
|
||||||
|
getTagConditionsFormArray(formGroup: FormGroup): FormArray {
|
||||||
|
return formGroup.get('tagConditions') as FormArray
|
||||||
|
}
|
||||||
|
|
||||||
|
getTagConditionLabel(type: TagConditionType): string {
|
||||||
|
return (
|
||||||
|
this.tagConditionOptions.find((option) => option.id === type)?.name ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
canAddTagCondition(formGroup: FormGroup): boolean {
|
||||||
|
const conditions = this.getTagConditionsFormArray(formGroup)
|
||||||
|
return conditions.length < this.tagConditionOptions.length
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conditions.push(this.createTagConditionFormGroup(availableTypes[0]))
|
||||||
|
triggerFormGroup.markAsDirty()
|
||||||
|
triggerFormGroup.markAsTouched()
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTagCondition(triggerIndex: number, conditionIndex: number) {
|
||||||
|
const triggerFormGroup = this.triggerFields.at(triggerIndex) as FormGroup
|
||||||
|
const conditions = this.getTagConditionsFormArray(triggerFormGroup)
|
||||||
|
conditions.removeAt(conditionIndex)
|
||||||
|
triggerFormGroup.markAsDirty()
|
||||||
|
triggerFormGroup.markAsTouched()
|
||||||
|
}
|
||||||
|
|
||||||
private createTriggerField(
|
private createTriggerField(
|
||||||
trigger: WorkflowTrigger,
|
trigger: WorkflowTrigger,
|
||||||
emitEvent: boolean = false
|
emitEvent: boolean = false
|
||||||
@@ -405,7 +574,6 @@ export class WorkflowEditDialogComponent
|
|||||||
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
||||||
match: new FormControl(trigger.match),
|
match: new FormControl(trigger.match),
|
||||||
is_insensitive: new FormControl(trigger.is_insensitive),
|
is_insensitive: new FormControl(trigger.is_insensitive),
|
||||||
filter_has_tags: new FormControl(trigger.filter_has_tags),
|
|
||||||
filter_has_correspondent: new FormControl(
|
filter_has_correspondent: new FormControl(
|
||||||
trigger.filter_has_correspondent
|
trigger.filter_has_correspondent
|
||||||
),
|
),
|
||||||
@@ -415,6 +583,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),
|
||||||
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(
|
||||||
@@ -537,6 +706,8 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_path: null,
|
filter_path: null,
|
||||||
filter_mailrule: null,
|
filter_mailrule: null,
|
||||||
filter_has_tags: [],
|
filter_has_tags: [],
|
||||||
|
filter_has_all_tags: [],
|
||||||
|
filter_has_not_tags: [],
|
||||||
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,
|
||||||
|
@@ -40,6 +40,10 @@ export interface WorkflowTrigger extends ObjectWithId {
|
|||||||
|
|
||||||
filter_has_tags?: number[] // Tag.id[]
|
filter_has_tags?: number[] // Tag.id[]
|
||||||
|
|
||||||
|
filter_has_all_tags?: number[] // Tag.id[]
|
||||||
|
|
||||||
|
filter_has_not_tags?: number[] // Tag.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
|
||||||
|
@@ -360,10 +360,9 @@ def existing_document_matches_workflow(
|
|||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document tags vs trigger has_tags
|
# Document tags vs trigger has_tags (any of)
|
||||||
if (
|
if trigger.filter_has_tags.all().count() > 0 and (
|
||||||
trigger.filter_has_tags.all().count() > 0
|
document.tags.filter(
|
||||||
and document.tags.filter(
|
|
||||||
id__in=trigger.filter_has_tags.all().values_list("id"),
|
id__in=trigger.filter_has_tags.all().values_list("id"),
|
||||||
).count()
|
).count()
|
||||||
== 0
|
== 0
|
||||||
@@ -374,6 +373,36 @@ def existing_document_matches_workflow(
|
|||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
|
# Document tags vs trigger has_all_tags (all of)
|
||||||
|
if trigger.filter_has_all_tags.all().count() > 0 and trigger_matched:
|
||||||
|
required_tag_ids = set(
|
||||||
|
trigger.filter_has_all_tags.all().values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
document_tag_ids = set(
|
||||||
|
document.tags.all().values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
missing_tags = required_tag_ids - document_tag_ids
|
||||||
|
if missing_tags:
|
||||||
|
reason = (
|
||||||
|
f"Document tags {document.tags.all()} do not contain all of"
|
||||||
|
f" {trigger.filter_has_all_tags.all()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
# Document tags vs trigger has_not_tags (none of)
|
||||||
|
if (
|
||||||
|
trigger.filter_has_not_tags.all().count() > 0
|
||||||
|
and trigger_matched
|
||||||
|
and document.tags.filter(
|
||||||
|
id__in=trigger.filter_has_not_tags.all().values_list("id"),
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
reason = (
|
||||||
|
f"Document tags {document.tags.all()} include excluded tags"
|
||||||
|
f" {trigger.filter_has_not_tags.all()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
# Document correspondent vs trigger has_correspondent
|
# Document correspondent vs trigger has_correspondent
|
||||||
if (
|
if (
|
||||||
trigger.filter_has_correspondent is not None
|
trigger.filter_has_correspondent is not None
|
||||||
@@ -438,6 +467,16 @@ def prefilter_documents_by_workflowtrigger(
|
|||||||
tags__in=trigger.filter_has_tags.all(),
|
tags__in=trigger.filter_has_tags.all(),
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
if trigger.filter_has_all_tags.all().count() > 0:
|
||||||
|
for tag_id in trigger.filter_has_all_tags.all().values_list("id", flat=True):
|
||||||
|
documents = documents.filter(tags__id=tag_id)
|
||||||
|
documents = documents.distinct()
|
||||||
|
|
||||||
|
if trigger.filter_has_not_tags.all().count() > 0:
|
||||||
|
documents = documents.exclude(
|
||||||
|
tags__in=trigger.filter_has_not_tags.all(),
|
||||||
|
).distinct()
|
||||||
|
|
||||||
if trigger.filter_has_correspondent is not None:
|
if trigger.filter_has_correspondent is not None:
|
||||||
documents = documents.filter(
|
documents = documents.filter(
|
||||||
correspondent=trigger.filter_has_correspondent,
|
correspondent=trigger.filter_has_correspondent,
|
||||||
|
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-07 16:09
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_all_tags",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_all",
|
||||||
|
to="documents.tag",
|
||||||
|
verbose_name="has all of these tag(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_not_tags",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not",
|
||||||
|
to="documents.tag",
|
||||||
|
verbose_name="does not have these tag(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@@ -1065,6 +1065,20 @@ class WorkflowTrigger(models.Model):
|
|||||||
verbose_name=_("has these tag(s)"),
|
verbose_name=_("has these tag(s)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filter_has_all_tags = models.ManyToManyField(
|
||||||
|
Tag,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_all",
|
||||||
|
verbose_name=_("has all of these tag(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_has_not_tags = models.ManyToManyField(
|
||||||
|
Tag,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not",
|
||||||
|
verbose_name=_("does not have these tag(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
filter_has_document_type = models.ForeignKey(
|
filter_has_document_type = models.ForeignKey(
|
||||||
DocumentType,
|
DocumentType,
|
||||||
null=True,
|
null=True,
|
||||||
|
@@ -2194,6 +2194,8 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
|||||||
"match",
|
"match",
|
||||||
"is_insensitive",
|
"is_insensitive",
|
||||||
"filter_has_tags",
|
"filter_has_tags",
|
||||||
|
"filter_has_all_tags",
|
||||||
|
"filter_has_not_tags",
|
||||||
"filter_has_correspondent",
|
"filter_has_correspondent",
|
||||||
"filter_has_document_type",
|
"filter_has_document_type",
|
||||||
"filter_has_storage_path",
|
"filter_has_storage_path",
|
||||||
@@ -2414,6 +2416,8 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
|||||||
if triggers is not None and triggers is not serializers.empty:
|
if triggers is not None and triggers is not serializers.empty:
|
||||||
for trigger in triggers:
|
for trigger in triggers:
|
||||||
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_not_tags = trigger.pop("filter_has_not_tags", 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(
|
||||||
@@ -2422,6 +2426,10 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
if filter_has_tags is not None:
|
if filter_has_tags is not None:
|
||||||
trigger_instance.filter_has_tags.set(filter_has_tags)
|
trigger_instance.filter_has_tags.set(filter_has_tags)
|
||||||
|
if filter_has_all_tags is not None:
|
||||||
|
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)
|
||||||
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:
|
||||||
|
@@ -184,6 +184,8 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
"filter_filename": "*",
|
"filter_filename": "*",
|
||||||
"filter_path": "*/samples/*",
|
"filter_path": "*/samples/*",
|
||||||
"filter_has_tags": [self.t1.id],
|
"filter_has_tags": [self.t1.id],
|
||||||
|
"filter_has_all_tags": [self.t2.id],
|
||||||
|
"filter_has_not_tags": [self.t3.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,
|
||||||
@@ -223,6 +225,20 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Workflow.objects.count(), 2)
|
self.assertEqual(Workflow.objects.count(), 2)
|
||||||
|
workflow = Workflow.objects.get(name="Workflow 2")
|
||||||
|
trigger = workflow.triggers.first()
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_tags.values_list("id", flat=True)),
|
||||||
|
{self.t1.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_all_tags.values_list("id", flat=True)),
|
||||||
|
{self.t2.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_not_tags.values_list("id", flat=True)),
|
||||||
|
{self.t3.id},
|
||||||
|
)
|
||||||
|
|
||||||
def test_api_create_invalid_workflow_trigger(self):
|
def test_api_create_invalid_workflow_trigger(self):
|
||||||
"""
|
"""
|
||||||
@@ -376,6 +392,8 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
{
|
{
|
||||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
"filter_has_tags": [self.t1.id],
|
"filter_has_tags": [self.t1.id],
|
||||||
|
"filter_has_all_tags": [self.t2.id],
|
||||||
|
"filter_has_not_tags": [self.t3.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,
|
||||||
},
|
},
|
||||||
@@ -393,6 +411,14 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
workflow = Workflow.objects.get(id=response.data["id"])
|
workflow = Workflow.objects.get(id=response.data["id"])
|
||||||
self.assertEqual(workflow.name, "Workflow Updated")
|
self.assertEqual(workflow.name, "Workflow Updated")
|
||||||
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
|
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_all_tags.first(),
|
||||||
|
self.t2,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_not_tags.first(),
|
||||||
|
self.t3,
|
||||||
|
)
|
||||||
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):
|
||||||
|
@@ -1083,6 +1083,82 @@ class TestWorkflows(
|
|||||||
expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}"
|
expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}"
|
||||||
self.assertIn(expected_str, cm.output[1])
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_no_match_all_tags(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_all_tags.set([self.t1, self.t2])
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
doc.tags.set([self.t1])
|
||||||
|
doc.save()
|
||||||
|
|
||||||
|
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 tags {doc.tags.all()} do not contain all of"
|
||||||
|
f" {trigger.filter_has_all_tags.all()}"
|
||||||
|
)
|
||||||
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_excluded_tags(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_not_tags.set([self.t3])
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
doc.tags.set([self.t3])
|
||||||
|
doc.save()
|
||||||
|
|
||||||
|
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 tags {doc.tags.all()} include excluded tags"
|
||||||
|
f" {trigger.filter_has_not_tags.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,
|
||||||
|
Reference in New Issue
Block a user