From 1ba7ec3cb9f61a5159a789e1822ca91e635cb664 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Tue, 23 Apr 2024 22:04:14 -0700
Subject: [PATCH] Custom field filtering
---
docker/docker-prepare.sh | 2 +-
.../bulk-editor/bulk-editor.component.spec.ts | 4 +
.../filter-editor.component.html | 12 +
.../filter-editor.component.spec.ts | 226 +++++++++++++++++-
.../filter-editor/filter-editor.component.ts | 104 +++++++-
src-ui/src/app/data/filter-rule-type.ts | 33 ++-
.../src/app/services/rest/document.service.ts | 1 +
src/documents/filters.py | 19 ++
src/documents/index.py | 18 +-
...047_alter_savedviewfilterrule_rule_type.py | 65 +++++
src/documents/models.py | 4 +
src/documents/tests/test_api_search.py | 28 +++
src/documents/views.py | 16 ++
13 files changed, 521 insertions(+), 11 deletions(-)
create mode 100644 src/documents/migrations/1047_alter_savedviewfilterrule_rule_type.py
diff --git a/docker/docker-prepare.sh b/docker/docker-prepare.sh
index adf2be839..30d1237e5 100755
--- a/docker/docker-prepare.sh
+++ b/docker/docker-prepare.sh
@@ -80,7 +80,7 @@ django_checks() {
search_index() {
- local -r index_version=8
+ local -r index_version=9
local -r index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
index 127d7ef2b..548b77e40 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
@@ -68,6 +68,10 @@ const selectionData: SelectionData = {
{ id: 66, document_count: 3 },
{ id: 55, document_count: 0 },
],
+ selected_custom_fields: [
+ { id: 1, document_count: 3 },
+ { id: 2, document_count: 0 },
+ ],
}
describe('BulkEditorComponent', () => {
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
index 89900e087..ceb19f4ea 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
@@ -70,6 +70,18 @@
[documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true">
}
+
+ @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
+
+ }
{
listAll: () => of({ results: storage_paths }),
},
},
+ {
+ provide: CustomFieldsService,
+ useValue: {
+ listAll: () => of({ results: custom_fields }),
+ },
+ },
{
provide: UserService,
useValue: {
@@ -285,7 +310,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
- rule_type: FILTER_CUSTOM_FIELDS,
+ rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
]
@@ -806,6 +831,110 @@ describe('FilterEditorComponent', () => {
]
}))
+ it('should ingest filter rules for has all custom fields', fakeAsync(() => {
+ expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: '42',
+ },
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: '43',
+ },
+ ]
+ expect(component.customFieldSelectionModel.logicalOperator).toEqual(
+ LogicalOperator.And
+ )
+ expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
+ custom_fields
+ )
+ // coverage
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: null,
+ },
+ ]
+ component.toggleTag(2) // coverage
+ }))
+
+ it('should ingest filter rules for has any custom fields', fakeAsync(() => {
+ expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+ value: '42',
+ },
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+ value: '43',
+ },
+ ]
+ expect(component.customFieldSelectionModel.logicalOperator).toEqual(
+ LogicalOperator.Or
+ )
+ expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
+ custom_fields
+ )
+ // coverage
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+ value: null,
+ },
+ ]
+ }))
+
+ it('should ingest filter rules for has any custom field', fakeAsync(() => {
+ expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
+ value: '1',
+ },
+ ]
+ expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
+ 1
+ )
+ expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
+ }))
+
+ it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
+ expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ value: '42',
+ },
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ value: '43',
+ },
+ ]
+ expect(component.customFieldSelectionModel.logicalOperator).toEqual(
+ LogicalOperator.And
+ )
+ expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
+ custom_fields
+ )
+ // coverage
+ component.filterRules = [
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ value: null,
+ },
+ ]
+ }))
+
it('should ingest filter rules for owner', fakeAsync(() => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
@@ -1053,7 +1182,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([
{
- rule_type: FILTER_CUSTOM_FIELDS,
+ rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
])
@@ -1317,6 +1446,75 @@ describe('FilterEditorComponent', () => {
])
}))
+ it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
+ const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
+ By.directive(FilterableDropdownComponent)
+ )[4]
+ customFieldsFilterableDropdown.triggerEventHandler('opened')
+ const customFieldButton = customFieldsFilterableDropdown.queryAll(
+ By.directive(ToggleableDropdownButtonComponent)
+ )[0]
+ customFieldButton.triggerEventHandler('toggle')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
+ value: 'false',
+ },
+ ])
+ }))
+
+ it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
+ const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
+ By.directive(FilterableDropdownComponent)
+ )[4] // CF dropdown
+ customFieldsFilterableDropdown.triggerEventHandler('opened')
+ const customFieldButtons = customFieldsFilterableDropdown.queryAll(
+ By.directive(ToggleableDropdownButtonComponent)
+ )
+ customFieldButtons[1].triggerEventHandler('toggle')
+ customFieldButtons[2].triggerEventHandler('toggle')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: custom_fields[0].id.toString(),
+ },
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: custom_fields[1].id.toString(),
+ },
+ ])
+ const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
+ By.css('input[type=radio]')
+ )
+ toggleOperatorButtons[1].nativeElement.checked = true
+ toggleOperatorButtons[1].triggerEventHandler('change')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+ value: custom_fields[0].id.toString(),
+ },
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+ value: custom_fields[1].id.toString(),
+ },
+ ])
+ customFieldButtons[2].triggerEventHandler('exclude')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: custom_fields[0].id.toString(),
+ },
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ value: custom_fields[1].id.toString(),
+ },
+ ])
+ }))
+
it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
@@ -1645,6 +1843,10 @@ describe('FilterEditorComponent', () => {
{ id: 32, document_count: 1 },
{ id: 33, document_count: 0 },
],
+ selected_custom_fields: [
+ { id: 42, document_count: 1 },
+ { id: 43, document_count: 0 },
+ ],
}
})
@@ -1719,6 +1921,24 @@ describe('FilterEditorComponent', () => {
]
expect(component.generateFilterName()).toEqual('Without any tag')
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ value: '42',
+ },
+ ]
+ expect(component.generateFilterName()).toEqual(
+ `Custom fields: ${custom_fields[0].name}`
+ )
+
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
+ value: 'false',
+ },
+ ]
+ expect(component.generateFilterName()).toEqual('Without any custom field')
+
component.filterRules = [
{
rule_type: FILTER_TITLE,
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
index a6aafe049..c8547f391 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
@@ -48,8 +48,12 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY,
- FILTER_CUSTOM_FIELDS,
+ FILTER_CUSTOM_FIELDS_TEXT,
FILTER_SHARED_BY_USER,
+ FILTER_HAS_CUSTOM_FIELDS_ANY,
+ FILTER_HAS_CUSTOM_FIELDS_ALL,
+ FILTER_HAS_ANY_CUSTOM_FIELDS,
+ FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@@ -76,6 +80,8 @@ import {
PermissionsService,
} from 'src/app/services/permissions.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { CustomField } from 'src/app/data/custom-field'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@@ -208,6 +214,16 @@ export class FilterEditorComponent
return $localize`Without any tag`
}
+ case FILTER_HAS_CUSTOM_FIELDS_ALL:
+ return $localize`Custom fields: ${this.customFields.find(
+ (f) => f.id == +rule.value
+ )?.name}`
+
+ case FILTER_HAS_ANY_CUSTOM_FIELDS:
+ if (rule.value == 'false') {
+ return $localize`Without any custom field`
+ }
+
case FILTER_TITLE:
return $localize`Title: ${rule.value}`
@@ -234,7 +250,8 @@ export class FilterEditorComponent
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService,
- public permissionsService: PermissionsService
+ public permissionsService: PermissionsService,
+ private customFieldService: CustomFieldsService
) {
super()
}
@@ -246,11 +263,13 @@ export class FilterEditorComponent
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
+ customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
+ customFieldDocumentCounts: SelectionDataItem[]
_textFilter = ''
_moreLikeId: number
@@ -288,6 +307,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
+ customFieldSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
@@ -322,6 +342,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
+ this.customFieldSelectionModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
@@ -347,7 +368,7 @@ export class FilterEditorComponent
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
- case FILTER_CUSTOM_FIELDS:
+ case FILTER_CUSTOM_FIELDS_TEXT:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
break
@@ -488,6 +509,36 @@ export class FilterEditorComponent
false
)
break
+ case FILTER_HAS_CUSTOM_FIELDS_ALL:
+ this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
+ this.customFieldSelectionModel.set(
+ rule.value ? +rule.value : null,
+ ToggleableItemState.Selected,
+ false
+ )
+ break
+ case FILTER_HAS_CUSTOM_FIELDS_ANY:
+ this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
+ this.customFieldSelectionModel.set(
+ rule.value ? +rule.value : null,
+ ToggleableItemState.Selected,
+ false
+ )
+ break
+ case FILTER_HAS_ANY_CUSTOM_FIELDS:
+ this.customFieldSelectionModel.set(
+ null,
+ ToggleableItemState.Selected,
+ false
+ )
+ break
+ case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
+ this.customFieldSelectionModel.set(
+ rule.value ? +rule.value : null,
+ ToggleableItemState.Excluded,
+ false
+ )
+ break
case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier =
@@ -595,7 +646,7 @@ export class FilterEditorComponent
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) {
filterRules.push({
- rule_type: FILTER_CUSTOM_FIELDS,
+ rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: this._textFilter,
})
}
@@ -703,6 +754,35 @@ export class FilterEditorComponent
})
})
}
+ if (this.customFieldSelectionModel.isNoneSelected()) {
+ filterRules.push({
+ rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
+ value: 'false',
+ })
+ } else {
+ const customFieldFilterType =
+ this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
+ ? FILTER_HAS_CUSTOM_FIELDS_ALL
+ : FILTER_HAS_CUSTOM_FIELDS_ANY
+ this.customFieldSelectionModel
+ .getSelectedItems()
+ .filter((field) => field.id)
+ .forEach((field) => {
+ filterRules.push({
+ rule_type: customFieldFilterType,
+ value: field.id?.toString(),
+ })
+ })
+ this.customFieldSelectionModel
+ .getExcludedItems()
+ .filter((field) => field.id)
+ .forEach((field) => {
+ filterRules.push({
+ rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ value: field.id?.toString(),
+ })
+ })
+ }
if (this.dateCreatedBefore) {
filterRules.push({
rule_type: FILTER_CREATED_BEFORE,
@@ -845,6 +925,8 @@ export class FilterEditorComponent
selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null
+ this.customFieldDocumentCounts =
+ selectionData?.selected_custom_fields ?? null
}
rulesModified: boolean = false
@@ -905,6 +987,16 @@ export class FilterEditorComponent
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
}
+ if (
+ this.permissionsService.currentUserCan(
+ PermissionAction.View,
+ PermissionType.CustomField
+ )
+ ) {
+ this.customFieldService
+ .listAll()
+ .subscribe((result) => (this.customFields = result.results))
+ }
this.textFilterDebounce = new Subject()
@@ -961,6 +1053,10 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply()
}
+ onCustomFieldsDropdownOpen() {
+ this.customFieldSelectionModel.apply()
+ }
+
updateTextFilter(text) {
this._textFilter = text
this.documentService.searchQuery = text
diff --git a/src-ui/src/app/data/filter-rule-type.ts b/src-ui/src/app/data/filter-rule-type.ts
index ee09f165d..cd4700096 100644
--- a/src-ui/src/app/data/filter-rule-type.ts
+++ b/src-ui/src/app/data/filter-rule-type.ts
@@ -47,7 +47,11 @@ export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
export const FILTER_SHARED_BY_USER = 37
-export const FILTER_CUSTOM_FIELDS = 36
+export const FILTER_CUSTOM_FIELDS_TEXT = 36
+export const FILTER_HAS_CUSTOM_FIELDS_ALL = 38
+export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
+export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
+export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
@@ -281,11 +285,36 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: true,
},
{
- id: FILTER_CUSTOM_FIELDS,
+ id: FILTER_CUSTOM_FIELDS_TEXT,
filtervar: 'custom_fields__icontains',
datatype: 'string',
multi: false,
},
+ {
+ id: FILTER_HAS_CUSTOM_FIELDS_ALL,
+ filtervar: 'custom_fields__id__all',
+ datatype: 'number',
+ multi: true,
+ },
+ {
+ id: FILTER_HAS_CUSTOM_FIELDS_ANY,
+ filtervar: 'custom_fields__id__in',
+ datatype: 'number',
+ multi: true,
+ },
+ {
+ id: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ filtervar: 'custom_fields__id__none',
+ datatype: 'number',
+ multi: true,
+ },
+ {
+ id: FILTER_HAS_ANY_CUSTOM_FIELDS,
+ filtervar: 'has_custom_fields',
+ datatype: 'boolean',
+ multi: false,
+ default: true,
+ },
]
export interface FilterRuleType {
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index 9780b9586..4d17bbd24 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -36,6 +36,7 @@ export interface SelectionData {
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
+ selected_custom_fields: SelectionDataItem[]
}
@Injectable({
diff --git a/src/documents/filters.py b/src/documents/filters.py
index 891f20dde..c548cfa22 100644
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -199,6 +199,25 @@ class DocumentFilterSet(FilterSet):
custom_fields__icontains = CustomFieldsFilter()
+ custom_fields__id__all = ObjectFilter(field_name="custom_fields__field")
+
+ custom_fields__id__none = ObjectFilter(
+ field_name="custom_fields__field",
+ exclude=True,
+ )
+
+ custom_fields__id__in = ObjectFilter(
+ field_name="custom_fields__field",
+ in_list=True,
+ )
+
+ has_custom_fields = BooleanFilter(
+ label="Has custom field",
+ field_name="custom_fields",
+ lookup_expr="isnull",
+ exclude=True,
+ )
+
shared_by__id = SharedByUser()
class Meta:
diff --git a/src/documents/index.py b/src/documents/index.py
index 388b994d8..98c43d1e8 100644
--- a/src/documents/index.py
+++ b/src/documents/index.py
@@ -70,6 +70,8 @@ def get_schema():
num_notes=NUMERIC(sortable=True, signed=False),
custom_fields=TEXT(),
custom_field_count=NUMERIC(sortable=True, signed=False),
+ has_custom_fields=BOOLEAN(),
+ custom_fields_id=KEYWORD(commas=True),
owner=TEXT(),
owner_id=NUMERIC(),
has_owner=BOOLEAN(),
@@ -125,6 +127,9 @@ def update_document(writer: AsyncWriter, doc: Document):
custom_fields = ",".join(
[str(c) for c in CustomFieldInstance.objects.filter(document=doc)],
)
+ custom_fields_ids = ",".join(
+ [str(f.field.id) for f in CustomFieldInstance.objects.filter(document=doc)],
+ )
asn = doc.archive_serial_number
if asn is not None and (
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
@@ -166,6 +171,8 @@ def update_document(writer: AsyncWriter, doc: Document):
num_notes=len(notes),
custom_fields=custom_fields,
custom_field_count=len(doc.custom_fields.all()),
+ has_custom_fields=len(custom_fields) > 0,
+ custom_fields_id=custom_fields_ids if custom_fields_ids else None,
owner=doc.owner.username if doc.owner else None,
owner_id=doc.owner.id if doc.owner else None,
has_owner=doc.owner is not None,
@@ -206,7 +213,10 @@ class DelayedQuery:
"created": ("created", ["date__lt", "date__gt"]),
"checksum": ("checksum", ["icontains", "istartswith"]),
"original_filename": ("original_filename", ["icontains", "istartswith"]),
- "custom_fields": ("custom_fields", ["icontains", "istartswith"]),
+ "custom_fields": (
+ "custom_fields",
+ ["icontains", "istartswith", "id__all", "id__in", "id__none"],
+ ),
}
def _get_query(self):
@@ -220,6 +230,12 @@ class DelayedQuery:
criterias.append(query.Term("has_tag", self.evalBoolean(value)))
continue
+ if key == "has_custom_fields":
+ criterias.append(
+ query.Term("has_custom_fields", self.evalBoolean(value)),
+ )
+ continue
+
# Don't process query params without a filter
if "__" not in key:
continue
diff --git a/src/documents/migrations/1047_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1047_alter_savedviewfilterrule_rule_type.py
new file mode 100644
index 000000000..c2adcee5d
--- /dev/null
+++ b/src/documents/migrations/1047_alter_savedviewfilterrule_rule_type.py
@@ -0,0 +1,65 @@
+# Generated by Django 4.2.11 on 2024-04-24 04:58
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("documents", "1046_workflowaction_remove_all_correspondents_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="savedviewfilterrule",
+ name="rule_type",
+ field=models.PositiveIntegerField(
+ choices=[
+ (0, "title contains"),
+ (1, "content contains"),
+ (2, "ASN is"),
+ (3, "correspondent is"),
+ (4, "document type is"),
+ (5, "is in inbox"),
+ (6, "has tag"),
+ (7, "has any tag"),
+ (8, "created before"),
+ (9, "created after"),
+ (10, "created year is"),
+ (11, "created month is"),
+ (12, "created day is"),
+ (13, "added before"),
+ (14, "added after"),
+ (15, "modified before"),
+ (16, "modified after"),
+ (17, "does not have tag"),
+ (18, "does not have ASN"),
+ (19, "title or content contains"),
+ (20, "fulltext query"),
+ (21, "more like this"),
+ (22, "has tags in"),
+ (23, "ASN greater than"),
+ (24, "ASN less than"),
+ (25, "storage path is"),
+ (26, "has correspondent in"),
+ (27, "does not have correspondent in"),
+ (28, "has document type in"),
+ (29, "does not have document type in"),
+ (30, "has storage path in"),
+ (31, "does not have storage path in"),
+ (32, "owner is"),
+ (33, "has owner in"),
+ (34, "does not have owner"),
+ (35, "does not have owner in"),
+ (36, "has custom field value"),
+ (37, "is shared by me"),
+ (38, "has custom fields"),
+ (39, "has custom field in"),
+ (40, "does not have custom field in"),
+ (41, "does not have custom field"),
+ ],
+ verbose_name="rule type",
+ ),
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 6d8a49350..f3e5f22ed 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -500,6 +500,10 @@ class SavedViewFilterRule(models.Model):
(35, _("does not have owner in")),
(36, _("has custom field value")),
(37, _("is shared by me")),
+ (38, _("has custom fields")),
+ (39, _("has custom field in")),
+ (40, _("does not have custom field in")),
+ (41, _("does not have custom field")),
]
saved_view = models.ForeignKey(
diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py
index 1b46f8e33..cfbcce74c 100644
--- a/src/documents/tests/test_api_search.py
+++ b/src/documents/tests/test_api_search.py
@@ -920,6 +920,34 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
),
)
+ self.assertIn(
+ d4.id,
+ search_query(
+ "&has_custom_fields=1",
+ ),
+ )
+
+ self.assertIn(
+ d4.id,
+ search_query(
+ "&custom_fields__id__in=" + str(cf1.id),
+ ),
+ )
+
+ self.assertIn(
+ d4.id,
+ search_query(
+ "&custom_fields__id__all=" + str(cf1.id),
+ ),
+ )
+
+ self.assertNotIn(
+ d4.id,
+ search_query(
+ "&custom_fields__id__none=" + str(cf1.id),
+ ),
+ )
+
def test_search_filtering_respect_owner(self):
"""
GIVEN:
diff --git a/src/documents/views.py b/src/documents/views.py
index d220d1aaa..e8c0bcc3a 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -1065,6 +1065,18 @@ class SelectionDataView(GenericAPIView):
),
)
+ custom_fields = CustomField.objects.annotate(
+ document_count=Count(
+ Case(
+ When(
+ fields__document__id__in=ids,
+ then=1,
+ ),
+ output_field=IntegerField(),
+ ),
+ ),
+ )
+
r = Response(
{
"selected_correspondents": [
@@ -1081,6 +1093,10 @@ class SelectionDataView(GenericAPIView):
{"id": t.id, "document_count": t.document_count}
for t in storage_paths
],
+ "selected_custom_fields": [
+ {"id": t.id, "document_count": t.document_count}
+ for t in custom_fields
+ ],
},
)