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 + ], }, )