From 1fed785c7d8cb8dc6d9b5d7f3e93ce390d4013cc Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Tue, 7 Oct 2025 09:20:37 -0700
Subject: [PATCH] Initial crack
---
.../workflow-edit-dialog.component.html | 48 ++++-
.../workflow-edit-dialog.component.spec.ts | 25 +++
.../workflow-edit-dialog.component.ts | 173 +++++++++++++++++-
src-ui/src/app/data/workflow-trigger.ts | 4 +
src/documents/matching.py | 47 ++++-
...lowtrigger_filter_has_all_tags_and_more.py | 33 ++++
src/documents/models.py | 14 ++
src/documents/serialisers.py | 8 +
src/documents/tests/test_api_workflows.py | 26 +++
src/documents/tests/test_workflows.py | 76 ++++++++
10 files changed, 448 insertions(+), 6 deletions(-)
create mode 100644 src/documents/migrations/1072_workflowtrigger_filter_has_all_tags_and_more.py
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,