diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 7163ba289..84d0cd262 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -174,7 +174,53 @@ @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
- +
+
+ + +
+
+ @if (getTagConditionsFormArray(formGroup).length === 0) { +

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

+ } + @for (condition of getTagConditionsFormArray(formGroup).controls; track condition; let conditionIndex = $index) { +
+
+
+ +
+ +
+ +
+ } +
+
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index 930164dce..0b8751148 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -375,6 +375,31 @@ describe('WorkflowEditDialogComponent', () => { 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', () => { const formGroup = new FormGroup({ assign_custom_fields: new FormControl([1, 2, 3]), diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index ec27d6c59..053688df2 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -135,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( (a) => a.id !== MATCH_AUTO ) @@ -170,6 +194,8 @@ export class WorkflowEditDialogComponent { public WorkflowTriggerType = WorkflowTriggerType public WorkflowActionType = WorkflowActionType + public TagConditionType = TagConditionType + public tagConditionOptions = TAG_CONDITION_OPTIONS private correspondentService: CorrespondentService private documentTypeService: DocumentTypeService @@ -390,6 +416,149 @@ export class WorkflowEditDialogComponent 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.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( trigger: WorkflowTrigger, emitEvent: boolean = false @@ -405,7 +574,6 @@ export class WorkflowEditDialogComponent matching_algorithm: new FormControl(trigger.matching_algorithm), match: new FormControl(trigger.match), is_insensitive: new FormControl(trigger.is_insensitive), - filter_has_tags: new FormControl(trigger.filter_has_tags), filter_has_correspondent: new FormControl( trigger.filter_has_correspondent ), @@ -415,6 +583,7 @@ export class WorkflowEditDialogComponent filter_has_storage_path: new FormControl( trigger.filter_has_storage_path ), + tagConditions: this.buildTagConditionsFormArray(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( @@ -537,6 +706,8 @@ export class WorkflowEditDialogComponent filter_path: null, filter_mailrule: null, filter_has_tags: [], + filter_has_all_tags: [], + filter_has_not_tags: [], filter_has_correspondent: null, filter_has_document_type: null, filter_has_storage_path: null, diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index 6e2d9cda7..4bad6f904 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -40,6 +40,10 @@ export interface WorkflowTrigger extends ObjectWithId { 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_document_type?: number // DocumentType.id diff --git a/src/documents/matching.py b/src/documents/matching.py index 72f1af5cf..6b1ed09bc 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -360,10 +360,9 @@ def existing_document_matches_workflow( ) trigger_matched = False - # Document tags vs trigger has_tags - if ( - trigger.filter_has_tags.all().count() > 0 - and document.tags.filter( + # Document tags vs trigger has_tags (any of) + if trigger.filter_has_tags.all().count() > 0 and ( + document.tags.filter( id__in=trigger.filter_has_tags.all().values_list("id"), ).count() == 0 @@ -374,6 +373,36 @@ def existing_document_matches_workflow( ) 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 if ( trigger.filter_has_correspondent is not None @@ -438,6 +467,16 @@ def prefilter_documents_by_workflowtrigger( tags__in=trigger.filter_has_tags.all(), ).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: documents = documents.filter( correspondent=trigger.filter_has_correspondent, diff --git a/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py b/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py new file mode 100644 index 000000000..4c9c1513c --- /dev/null +++ b/src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py @@ -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)", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 8d542cd8c..bff120efc 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1065,6 +1065,20 @@ class WorkflowTrigger(models.Model): 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( DocumentType, null=True, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index ce0192074..b1d09bf96 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2194,6 +2194,8 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): "match", "is_insensitive", "filter_has_tags", + "filter_has_all_tags", + "filter_has_not_tags", "filter_has_correspondent", "filter_has_document_type", "filter_has_storage_path", @@ -2414,6 +2416,8 @@ class WorkflowSerializer(serializers.ModelSerializer): if triggers is not None and triggers is not serializers.empty: for trigger in triggers: 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 WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger) trigger_instance, _ = WorkflowTrigger.objects.update_or_create( @@ -2422,6 +2426,10 @@ class WorkflowSerializer(serializers.ModelSerializer): ) if filter_has_tags is not None: 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) if actions is not None and actions is not serializers.empty: diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index 305467048..ace7782ee 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -184,6 +184,8 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_filename": "*", "filter_path": "*/samples/*", "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_correspondent": self.c.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(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): """ @@ -376,6 +392,8 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): { "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, "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_document_type": self.dt.id, }, @@ -393,6 +411,14 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): workflow = Workflow.objects.get(id=response.data["id"]) self.assertEqual(workflow.name, "Workflow Updated") 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") def test_api_update_workflow_no_trigger_actions(self): diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 7652d22b5..0ed041421 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -1083,6 +1083,82 @@ class TestWorkflows( expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}" 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): trigger = WorkflowTrigger.objects.create( type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,