Support CF queries!

This commit is contained in:
shamoon
2025-10-07 11:53:47 -07:00
parent 3d9cf696a7
commit 88fcc5f339
12 changed files with 457 additions and 56 deletions

View File

@@ -1,28 +1,36 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
@if (useDropdown) {
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
</div>
</div>
</div>
} @else {
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
}
<ng-template #list let-queries="queries">
<div class="list-group list-group-flush">
@for (element of queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</ng-template>
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {

View File

@@ -206,6 +206,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Input()
applyOnClose = false
@Input()
useDropdown: boolean = true
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}

View File

@@ -210,6 +210,19 @@
[title]="null"
formControlName="values"
></pngx-input-tags>
} @else if (
isCustomFieldQueryCondition(condition.get('type').value)
) {
<pngx-custom-fields-query-dropdown
[selectionModel]="getCustomFieldQueryModel(condition)"
(selectionModelChange)="onCustomFieldQuerySelectionChange(condition, $event)"
[useDropdown]="false"
></pngx-custom-fields-query-dropdown>
@if (!isCustomFieldQueryValid(condition)) {
<div class="text-danger small" i18n>
Complete the custom field query configuration.
</div>
}
} @else {
<pngx-input-select
[items]="getConditionSelectItems(condition.get('type').value)"

View File

@@ -410,11 +410,7 @@ describe('WorkflowEditDialogComponent', () => {
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)
const newCondition = component.addCondition(triggerGroup as FormGroup)
newCondition.get('type').setValue(type)
return newCondition
}
@@ -447,6 +443,12 @@ describe('WorkflowEditDialogComponent', () => {
)
storagePathNot.get('values').setValue([1])
const customFieldCondition = addConditionOfType(
TriggerConditionType.CustomFieldQuery
)
const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
customFieldCondition.get('values').setValue(customFieldQuery)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
@@ -458,6 +460,9 @@ describe('WorkflowEditDialogComponent', () => {
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].filter_custom_field_query).toEqual(
customFieldQuery
)
expect(formValues.triggers[0].conditions).toBeUndefined()
})
@@ -506,12 +511,22 @@ describe('WorkflowEditDialogComponent', () => {
trigger.filter_has_not_document_types = [8] as any
trigger.filter_has_storage_path = 9 as any
trigger.filter_has_not_storage_paths = [10] as any
trigger.filter_custom_field_query = JSON.stringify([
'AND',
[[1, 'exact', 'value']],
]) as any
component.object = workflow
component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const conditions = component.getConditionsFormArray(triggerGroup)
expect(conditions.length).toBe(9)
expect(conditions.length).toBe(10)
const customFieldCondition = conditions.at(9) as FormGroup
expect(customFieldCondition.get('type').value).toBe(
TriggerConditionType.CustomFieldQuery
)
const model = component.getCustomFieldQueryModel(customFieldCondition)
expect(model.isValid()).toBe(true)
})
it('should expose select metadata helpers', () => {
@@ -538,6 +553,12 @@ describe('WorkflowEditDialogComponent', () => {
expect(
component.getConditionSelectItems(TriggerConditionType.TagsAll)
).toEqual([])
expect(
component.isCustomFieldQueryCondition(
TriggerConditionType.CustomFieldQuery
)
).toBe(true)
})
it('should normalize condition values for single and multi selects', () => {
@@ -562,6 +583,13 @@ describe('WorkflowEditDialogComponent', () => {
8
)
).toEqual(8)
const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
expect(
component['normalizeConditionValue'](
TriggerConditionType.CustomFieldQuery,
customFieldJson
)
).toEqual(customFieldJson)
})
it('should add and remove condition form groups', () => {

View File

@@ -6,6 +6,7 @@ import {
import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import {
AbstractControl,
FormArray,
FormControl,
FormGroup,
@@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component'
@@ -145,18 +151,23 @@ export enum TriggerConditionType {
DocumentTypeNot = 'document_type_not',
StoragePathIs = 'storage_path_is',
StoragePathNot = 'storage_path_not',
CustomFieldQuery = 'custom_field_query',
}
interface TriggerConditionDefinition {
id: TriggerConditionType
name: string
inputType: 'tags' | 'select'
inputType: 'tags' | 'select' | 'customFieldQuery'
allowMultipleEntries: boolean
allowMultipleValues: boolean
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
disabled?: boolean
}
type TriggerConditionOption = TriggerConditionDefinition & {
disabled?: boolean
}
const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [
{
id: TriggerConditionType.TagsAny,
@@ -227,6 +238,13 @@ const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [
allowMultipleValues: true,
selectItems: 'storagePaths',
},
{
id: TriggerConditionType.CustomFieldQuery,
name: $localize`Matches custom field query`,
inputType: 'customFieldQuery',
allowMultipleEntries: false,
allowMultipleValues: false,
},
]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
@@ -247,6 +265,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
TextAreaComponent,
TagsComponent,
CustomFieldsValuesComponent,
CustomFieldsQueryDropdownComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
ConfirmButtonComponent,
@@ -287,7 +306,12 @@ export class WorkflowEditDialogComponent
private conditionTypeOptionCache = new WeakMap<
FormArray,
TriggerConditionDefinition[]
TriggerConditionOption[]
>()
private customFieldQueryModels = new WeakMap<
FormGroup,
CustomFieldQueriesModel
>()
constructor() {
@@ -510,6 +534,7 @@ export class WorkflowEditDialogComponent
filter_has_correspondent: null as number | null,
filter_has_document_type: null as number | null,
filter_has_storage_path: null as number | null,
filter_custom_field_query: null as string | null,
}
conditions.controls.forEach((control) => {
@@ -558,6 +583,9 @@ export class WorkflowEditDialogComponent
case TriggerConditionType.StoragePathNot:
aggregate.filter_has_not_storage_paths = [...values]
break
case TriggerConditionType.CustomFieldQuery:
aggregate.filter_custom_field_query = values as string
break
}
})
@@ -576,6 +604,8 @@ export class WorkflowEditDialogComponent
aggregate.filter_has_document_type ?? null
trigger.filter_has_storage_path =
aggregate.filter_has_storage_path ?? null
trigger.filter_custom_field_query =
aggregate.filter_custom_field_query ?? null
delete trigger.conditions
@@ -593,7 +623,7 @@ export class WorkflowEditDialogComponent
private createConditionFormGroup(
type: TriggerConditionType,
initialValue?: number | number[]
initialValue?: any
): FormGroup {
const group = new FormGroup({
type: new FormControl(type),
@@ -603,11 +633,20 @@ export class WorkflowEditDialogComponent
group
.get('type')
.valueChanges.subscribe((newType: TriggerConditionType) => {
group.get('values').setValue(this.getDefaultConditionValue(newType), {
emitEvent: false,
})
if (newType === TriggerConditionType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group)
} else {
this.teardownCustomFieldQueryModel(group)
group.get('values').setValue(this.getDefaultConditionValue(newType), {
emitEvent: false,
})
}
})
if (type === TriggerConditionType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group, initialValue)
}
return group
}
@@ -704,6 +743,15 @@ export class WorkflowEditDialogComponent
)
}
if (trigger.filter_custom_field_query) {
conditions.push(
this.createConditionFormGroup(
TriggerConditionType.CustomFieldQuery,
trigger.filter_custom_field_query
)
)
}
return conditions
}
@@ -753,10 +801,10 @@ export class WorkflowEditDialogComponent
})
}
addCondition(triggerFormGroup: FormGroup) {
addCondition(triggerFormGroup: FormGroup): FormGroup | null {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) {
return
return null
}
const conditions = this.getConditionsFormArray(triggerFormGroup)
@@ -771,12 +819,14 @@ export class WorkflowEditDialogComponent
})
if (!availableDefinition) {
return
return null
}
conditions.push(this.createConditionFormGroup(availableDefinition.id))
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
return conditions.at(conditions.length - 1) as FormGroup
}
removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) {
@@ -786,6 +836,13 @@ export class WorkflowEditDialogComponent
}
const conditions = this.getConditionsFormArray(triggerFormGroup)
const conditionGroup = conditions.at(conditionIndex) as FormGroup
if (
conditionGroup?.get('type').value ===
TriggerConditionType.CustomFieldQuery
) {
this.teardownCustomFieldQueryModel(conditionGroup)
}
conditions.removeAt(conditionIndex)
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
@@ -807,6 +864,10 @@ export class WorkflowEditDialogComponent
return this.getConditionDefinition(type)?.inputType === 'tags'
}
isCustomFieldQueryCondition(type: TriggerConditionType): boolean {
return this.getConditionDefinition(type)?.inputType === 'customFieldQuery'
}
isMultiValueCondition(type: TriggerConditionType): boolean {
switch (type) {
case TriggerConditionType.TagsAny:
@@ -843,18 +904,124 @@ export class WorkflowEditDialogComponent
}
}
getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
const conditionGroup = control as FormGroup
this.ensureCustomFieldQueryModel(conditionGroup)
return this.customFieldQueryModels.get(conditionGroup)
}
onCustomFieldQuerySelectionChange(
control: AbstractControl,
model: CustomFieldQueriesModel
) {
this.onCustomFieldQueryModelChanged(control as FormGroup, model)
}
isCustomFieldQueryValid(control: AbstractControl): boolean {
const model = this.customFieldQueryModels.get(control as FormGroup)
if (!model) {
return true
}
return model.isEmpty() || model.isValid()
}
private getConditionTypeOptionsForArray(
conditions: FormArray
): TriggerConditionOption[] {
let cached = this.conditionTypeOptionCache.get(conditions)
if (!cached) {
cached = this.conditionDefinitions.map((definition) => ({
...definition,
disabled: false,
}))
this.conditionTypeOptionCache.set(conditions, cached)
}
return cached
}
private ensureCustomFieldQueryModel(
conditionGroup: FormGroup,
initialValue?: any
) {
if (this.customFieldQueryModels.has(conditionGroup)) {
return
}
const model = new CustomFieldQueriesModel()
this.customFieldQueryModels.set(conditionGroup, model)
const rawValue =
typeof initialValue === 'string'
? initialValue
: (conditionGroup.get('values').value as string)
if (rawValue) {
try {
const parsed = JSON.parse(rawValue)
const expression = new CustomFieldQueryExpression(parsed)
model.queries = [expression]
} catch (error) {
model.clear(false)
}
}
model.changed.subscribe(() => {
this.onCustomFieldQueryModelChanged(conditionGroup, model)
})
this.onCustomFieldQueryModelChanged(conditionGroup, model)
}
private teardownCustomFieldQueryModel(conditionGroup: FormGroup) {
if (!this.customFieldQueryModels.has(conditionGroup)) {
return
}
this.customFieldQueryModels.delete(conditionGroup)
}
private onCustomFieldQueryModelChanged(
conditionGroup: FormGroup,
model: CustomFieldQueriesModel
) {
const control = conditionGroup.get('values')
if (!control) {
return
}
if (!model.isValid()) {
control.setValue(null, { emitEvent: false })
return
}
if (model.isEmpty()) {
control.setValue(null, { emitEvent: false })
return
}
const serialized = JSON.stringify(model.queries[0].serialize())
control.setValue(serialized, { emitEvent: false })
}
private getDefaultConditionValue(type: TriggerConditionType) {
if (type === TriggerConditionType.CustomFieldQuery) {
return null
}
return this.isMultiValueCondition(type) ? [] : null
}
private normalizeConditionValue(
type: TriggerConditionType,
value?: number | number[]
) {
private normalizeConditionValue(type: TriggerConditionType, value?: any) {
if (value === undefined || value === null) {
return this.getDefaultConditionValue(type)
}
if (type === TriggerConditionType.CustomFieldQuery) {
if (typeof value === 'string') {
return value
}
return value ? JSON.stringify(value) : null
}
if (this.isMultiValueCondition(type)) {
return Array.isArray(value) ? [...value] : [value]
}
@@ -866,20 +1033,6 @@ export class WorkflowEditDialogComponent
return value
}
private getConditionTypeOptionsForArray(
conditions: FormArray
): TriggerConditionDefinition[] {
let cached = this.conditionTypeOptionCache.get(conditions)
if (!cached) {
cached = this.conditionDefinitions.map((definition) => ({
...definition,
disabled: false,
}))
this.conditionTypeOptionCache.set(conditions, cached)
}
return cached
}
private createTriggerField(
trigger: WorkflowTrigger,
emitEvent: boolean = false
@@ -1032,6 +1185,7 @@ export class WorkflowEditDialogComponent
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_custom_field_query: null,
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,

View File

@@ -50,6 +50,8 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_not_storage_paths?: number[] // StoragePath.id[]
filter_custom_field_query?: string
filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id

View File

@@ -6,8 +6,11 @@ from fnmatch import fnmatch
from fnmatch import translate as fnmatch_translate
from typing import TYPE_CHECKING
from rest_framework import serializers
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
@@ -475,6 +478,25 @@ def existing_document_matches_workflow(
)
trigger_matched = False
if trigger_matched and trigger.filter_custom_field_query:
parser = CustomFieldQueryParser("filter_custom_field_query")
try:
custom_field_q, annotations = parser.parse(
trigger.filter_custom_field_query,
)
except serializers.ValidationError:
reason = "Invalid custom field query configuration"
trigger_matched = False
else:
qs = (
Document.objects.filter(id=document.id)
.annotate(**annotations)
.filter(custom_field_q)
)
if not qs.exists():
reason = "Document custom fields do not match the configured custom field query"
trigger_matched = False
# Document original_filename vs trigger filename
if (
trigger.filter_filename is not None
@@ -549,6 +571,17 @@ def prefilter_documents_by_workflowtrigger(
storage_path__in=trigger.filter_has_not_storage_paths.all(),
)
if trigger.filter_custom_field_query:
parser = CustomFieldQueryParser("filter_custom_field_query")
try:
custom_field_q, annotations = parser.parse(
trigger.filter_custom_field_query,
)
except serializers.ValidationError:
return documents.none()
documents = documents.annotate(**annotations).filter(custom_field_q)
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:22
# Generated by Django 5.2.6 on 2025-10-07 18:52
from django.db import migrations
from django.db import models
@@ -10,6 +10,16 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name="workflowtrigger",
name="filter_custom_field_query",
field=models.TextField(
blank=True,
help_text="JSON-encoded custom field query expression.",
null=True,
verbose_name="filter custom field query",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_all_tags",

View File

@@ -1124,6 +1124,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("does not have these storage path(s)"),
)
filter_custom_field_query = models.TextField(
_("filter custom field query"),
null=True,
blank=True,
help_text=_("JSON-encoded custom field query expression."),
)
schedule_offset_days = models.IntegerField(
_("schedule offset days"),
default=0,

View File

@@ -43,6 +43,7 @@ if settings.AUDIT_LOG_ENABLED:
from documents import bulk_edit
from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -2196,6 +2197,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_custom_field_query",
"filter_has_not_correspondents",
"filter_has_not_document_types",
"filter_has_not_storage_paths",
@@ -2224,6 +2226,20 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
):
attrs["filter_path"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
and len(attrs["filter_custom_field_query"]) == 0
):
attrs["filter_custom_field_query"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
):
parser = CustomFieldQueryParser("filter_custom_field_query")
parser.parse(attrs["filter_custom_field_query"])
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
if (
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION

View File

@@ -189,6 +189,12 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_not_correspondents": [self.c2.id],
"filter_has_not_document_types": [self.dt2.id],
"filter_has_not_storage_paths": [self.sp2.id],
"filter_custom_field_query": json.dumps(
[
"AND",
[[self.cf1.id, "exact", "value"]],
],
),
"filter_has_document_type": self.dt.id,
"filter_has_correspondent": self.c.id,
"filter_has_storage_path": self.sp.id,
@@ -254,6 +260,10 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)),
{self.sp2.id},
)
self.assertEqual(
trigger.filter_custom_field_query,
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
)
def test_api_create_invalid_workflow_trigger(self):
"""
@@ -412,6 +422,9 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_not_correspondents": [self.c2.id],
"filter_has_not_document_types": [self.dt2.id],
"filter_has_not_storage_paths": [self.sp2.id],
"filter_custom_field_query": json.dumps(
["AND", [[self.cf1.id, "exact", "value"]]],
),
"filter_has_correspondent": self.c.id,
"filter_has_document_type": self.dt.id,
},
@@ -449,6 +462,10 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
workflow.triggers.first().filter_has_not_storage_paths.first(),
self.sp2,
)
self.assertEqual(
workflow.triggers.first().filter_custom_field_query,
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
)
self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
def test_api_update_workflow_no_trigger_actions(self):

View File

@@ -1,4 +1,5 @@
import datetime
import json
import shutil
import socket
from datetime import timedelta
@@ -31,6 +32,7 @@ from documents import tasks
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.matching import document_matches_workflow
from documents.matching import existing_document_matches_workflow
from documents.matching import prefilter_documents_by_workflowtrigger
from documents.models import Correspondent
from documents.models import CustomField
@@ -1267,6 +1269,114 @@ class TestWorkflows(
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_custom_field_query_no_match(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query=json.dumps(
[
"AND",
[[self.cf1.id, "exact", "expected"]],
],
),
)
action = WorkflowAction.objects.create(
assign_title="Doc assign owner",
assign_owner=self.user2,
)
workflow = Workflow.objects.create(name="Workflow 1", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(action)
workflow.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
CustomFieldInstance.objects.create(
document=doc,
field=self.cf1,
value_text="other",
)
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 {workflow}"
self.assertIn(expected_str, cm.output[0])
self.assertIn(
"Document custom fields do not match the configured custom field query",
cm.output[1],
)
def test_document_added_custom_field_query_match(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query=json.dumps(
[
"AND",
[[self.cf1.id, "exact", "expected"]],
],
),
)
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
CustomFieldInstance.objects.create(
document=doc,
field=self.cf1,
value_text="expected",
)
matched, reason = existing_document_matches_workflow(doc, trigger)
self.assertTrue(matched)
self.assertEqual(reason, "")
def test_prefilter_documents_custom_field_query(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_custom_field_query=json.dumps(
[
"AND",
[[self.cf1.id, "exact", "match"]],
],
),
)
doc1 = Document.objects.create(
title="doc 1",
correspondent=self.c,
original_filename="doc1.pdf",
checksum="checksum1",
)
CustomFieldInstance.objects.create(
document=doc1,
field=self.cf1,
value_text="match",
)
doc2 = Document.objects.create(
title="doc 2",
correspondent=self.c,
original_filename="doc2.pdf",
checksum="checksum2",
)
CustomFieldInstance.objects.create(
document=doc2,
field=self.cf1,
value_text="different",
)
filtered = prefilter_documents_by_workflowtrigger(
Document.objects.all(),
trigger,
)
self.assertIn(doc1, filtered)
self.assertNotIn(doc2, filtered)
def test_document_added_no_match_doctype(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,