mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-31 10:37:12 -04:00 
			
		
		
		
	Enhancement: custom field sorting (#8494)
This commit is contained in:
		
							parent
							
								
									e44cfef662
								
							
						
					
					
						commit
						4e3d25c714
					
				| @ -5942,7 +5942,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">289</context> | ||||
|           <context context-type="linenumber">294</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="78870852467682010" datatype="html"> | ||||
| @ -5957,7 +5957,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">329</context> | ||||
|           <context context-type="linenumber">334</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="157572966557284263" datatype="html"> | ||||
| @ -5972,7 +5972,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">336</context> | ||||
|           <context context-type="linenumber">341</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="872092479747931526" datatype="html"> | ||||
| @ -7209,7 +7209,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">305</context> | ||||
|           <context context-type="linenumber">310</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="106713086593101376" datatype="html"> | ||||
| @ -7573,25 +7573,32 @@ | ||||
|           <context context-type="linenumber">261,263</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5083658411133224968" datatype="html"> | ||||
|         <source>Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">268,269</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2179847500064178686" datatype="html"> | ||||
|         <source>Edit document</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">297</context> | ||||
|           <context context-type="linenumber">302</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3420321797707163677" datatype="html"> | ||||
|         <source>Preview document</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">298</context> | ||||
|           <context context-type="linenumber">303</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2807800733729323332" datatype="html"> | ||||
|         <source>Yes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">357</context> | ||||
|           <context context-type="linenumber">362</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context> | ||||
| @ -7602,7 +7609,7 @@ | ||||
|         <source>No</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">357</context> | ||||
|           <context context-type="linenumber">362</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context> | ||||
|  | ||||
| @ -262,9 +262,14 @@ | ||||
|                   Shared | ||||
|                 </th> | ||||
|               } | ||||
|               @for (field of activeDisplayCustomFields; track field) { | ||||
|                 <th> | ||||
|                   {{getDisplayCustomFieldTitle(field)}} | ||||
|               @for (field_id of activeDisplayCustomFields; track field_id) { | ||||
|                 <th class="cursor-pointer" | ||||
|                   pngxSortable="{{field_id}}" | ||||
|                   title="Sort by {{getDisplayCustomFieldTitle(field_id)}}" i18n-title | ||||
|                   [currentSortField]="list.sortField" | ||||
|                   [currentSortReverse]="list.sortReverse" | ||||
|                   (sort)="onSort($event)"> | ||||
|                   {{getDisplayCustomFieldTitle(field_id)}} | ||||
|                 </th> | ||||
|               } | ||||
|             </tr> | ||||
|  | ||||
| @ -11,6 +11,7 @@ import { SavedView } from 'src/app/data/saved-view' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' | ||||
| @ -60,6 +61,17 @@ describe('SavedViewsComponent', () => { | ||||
|             currentUserCan: () => true, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           provide: CustomFieldsService, | ||||
|           useValue: { | ||||
|             listAll: () => | ||||
|               of({ | ||||
|                 all: [], | ||||
|                 count: 0, | ||||
|                 results: [], | ||||
|               }), | ||||
|           }, | ||||
|         }, | ||||
|         PermissionsGuard, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|  | ||||
| @ -156,7 +156,7 @@ describe('DocumentListViewService', () => { | ||||
|     expect(documentListViewService.currentPage).toEqual(1) | ||||
|   }) | ||||
| 
 | ||||
|   it('should handle error on filtering request', () => { | ||||
|   it('should handle object error on filtering request', () => { | ||||
|     documentListViewService.currentPage = 1 | ||||
|     const tags__id__in = 'hello' | ||||
|     const filterRulesAny = [ | ||||
| @ -185,6 +185,50 @@ describe('DocumentListViewService', () => { | ||||
|     ) | ||||
|   }) | ||||
| 
 | ||||
|   it('should handle object error on filtering request for custom field sorts', () => { | ||||
|     documentListViewService.currentPage = 1 | ||||
|     documentListViewService.sortField = 'custom_field_999' | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|     req.flush( | ||||
|       { custom_field_999: ['Custom field not found'] }, | ||||
|       { status: 400, statusText: 'Unexpected error' } | ||||
|     ) | ||||
|     expect(documentListViewService.error).toEqual( | ||||
|       'custom_field_999: Custom field not found' | ||||
|     ) | ||||
|     // reset the list
 | ||||
|     documentListViewService.sortField = 'created' | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|   }) | ||||
| 
 | ||||
|   it('should handle string error on filtering request', () => { | ||||
|     documentListViewService.currentPage = 1 | ||||
|     const tags__id__in = 'hello' | ||||
|     const filterRulesAny = [ | ||||
|       { | ||||
|         rule_type: FILTER_HAS_TAGS_ANY, | ||||
|         value: tags__id__in, | ||||
|       }, | ||||
|     ] | ||||
|     documentListViewService.filterRules = filterRulesAny | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|     req.flush('Generic error', { status: 404, statusText: 'Unexpected error' }) | ||||
|     expect(documentListViewService.error).toEqual('Generic error') | ||||
|     // reset the list
 | ||||
|     documentListViewService.filterRules = [] | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|   }) | ||||
| 
 | ||||
|   it('should support setting sort', () => { | ||||
|     expect(documentListViewService.sortField).toEqual('created') | ||||
|     expect(documentListViewService.sortReverse).toBeTruthy() | ||||
|  | ||||
| @ -307,18 +307,23 @@ export class DocumentListViewService { | ||||
|             activeListViewState.currentPage = 1 | ||||
|             this.reload() | ||||
|           } else { | ||||
|             console.log(error) | ||||
| 
 | ||||
|             this.selectionData = null | ||||
|             let errorMessage | ||||
|             if ( | ||||
|               typeof error.error !== 'string' && | ||||
|               typeof error.error === 'object' && | ||||
|               Object.keys(error.error).length > 0 | ||||
|             ) { | ||||
|               // e.g. { archive_serial_number: Array<string> }
 | ||||
|               errorMessage = Object.keys(error.error) | ||||
|                 .map((fieldName) => { | ||||
|                   const fieldNameBase = fieldName.split('__')[0] | ||||
|                   const fieldError: Array<string> = error.error[fieldName] | ||||
|                   return `${ | ||||
|                     this.sortFields.find((f) => f.field == fieldName)?.name | ||||
|                     this.sortFields.find( | ||||
|                       (f) => f.field?.split('__')[0] == fieldNameBase | ||||
|                     )?.name ?? fieldNameBase | ||||
|                   }: ${fieldError[0]}` | ||||
|                 }) | ||||
|                 .join(', ') | ||||
|  | ||||
| @ -4,7 +4,8 @@ import { | ||||
|   provideHttpClientTesting, | ||||
| } from '@angular/common/http/testing' | ||||
| import { TestBed } from '@angular/core/testing' | ||||
| import { Subscription } from 'rxjs' | ||||
| import { of, Subscription } from 'rxjs' | ||||
| import { CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
| @ -14,12 +15,15 @@ import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { PermissionsService } from '../permissions.service' | ||||
| import { SettingsService } from '../settings.service' | ||||
| import { CustomFieldsService } from './custom-fields.service' | ||||
| import { DocumentService } from './document.service' | ||||
| 
 | ||||
| let httpTestingController: HttpTestingController | ||||
| let service: DocumentService | ||||
| let subscription: Subscription | ||||
| let settingsService: SettingsService | ||||
| let permissionsService: PermissionsService | ||||
| let customFieldsService: CustomFieldsService | ||||
| 
 | ||||
| const endpoint = 'documents' | ||||
| const documents = [ | ||||
| @ -55,8 +59,29 @@ beforeEach(() => { | ||||
|   }) | ||||
| 
 | ||||
|   httpTestingController = TestBed.inject(HttpTestingController) | ||||
|   service = TestBed.inject(DocumentService) | ||||
|   settingsService = TestBed.inject(SettingsService) | ||||
|   customFieldsService = TestBed.inject(CustomFieldsService) | ||||
|   permissionsService = TestBed.inject(PermissionsService) | ||||
|   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|   jest.spyOn(customFieldsService, 'listAll').mockReturnValue( | ||||
|     of({ | ||||
|       all: [1, 2, 3], | ||||
|       count: 3, | ||||
|       results: [ | ||||
|         { | ||||
|           id: 1, | ||||
|           name: 'Custom Field 1', | ||||
|           data_type: CustomFieldDataType.String, | ||||
|         }, | ||||
|         { | ||||
|           id: 2, | ||||
|           name: 'Custom Field 2', | ||||
|           data_type: CustomFieldDataType.Integer, | ||||
|         }, | ||||
|       ], | ||||
|     }) | ||||
|   ) | ||||
|   service = TestBed.inject(DocumentService) | ||||
| }) | ||||
| 
 | ||||
| describe(`DocumentService`, () => { | ||||
| @ -289,18 +314,25 @@ describe(`DocumentService`, () => { | ||||
| it('should construct sort fields respecting permissions', () => { | ||||
|   expect( | ||||
|     service.sortFields.find((f) => f.field === 'correspondent__name') | ||||
|   ).toBeUndefined() | ||||
|   ).not.toBeUndefined() | ||||
|   expect( | ||||
|     service.sortFields.find((f) => f.field === 'document_type__name') | ||||
|   ).toBeUndefined() | ||||
|   ).not.toBeUndefined() | ||||
|   expect( | ||||
|     service.sortFields.find((f) => f.field === 'owner') | ||||
|   ).not.toBeUndefined() | ||||
| 
 | ||||
|   const permissionsService: PermissionsService = | ||||
|     TestBed.inject(PermissionsService) | ||||
|   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) | ||||
|   service['setupSortFields']() | ||||
|   expect(service.sortFields).toEqual(DOCUMENT_SORT_FIELDS) | ||||
|   const fields = DOCUMENT_SORT_FIELDS.filter( | ||||
|     (f) => | ||||
|       ['correspondent__name', 'document_type__name', 'owner'].indexOf( | ||||
|         f.field | ||||
|       ) === -1 | ||||
|   ) | ||||
|   expect(service.sortFields).toEqual(fields) | ||||
|   expect(service.sortFieldsFullText).toEqual([ | ||||
|     ...DOCUMENT_SORT_FIELDS, | ||||
|     ...fields, | ||||
|     ...DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
|   ]) | ||||
| 
 | ||||
| @ -311,6 +343,38 @@ it('should construct sort fields respecting permissions', () => { | ||||
|   ).toBeUndefined() | ||||
| }) | ||||
| 
 | ||||
| it('should include custom fields in sort fields if user has permission', () => { | ||||
|   const permissionsService: PermissionsService = | ||||
|     TestBed.inject(PermissionsService) | ||||
|   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
| 
 | ||||
|   service['customFields'] = [ | ||||
|     { | ||||
|       id: 1, | ||||
|       name: 'Custom Field 1', | ||||
|       data_type: CustomFieldDataType.String, | ||||
|     }, | ||||
|     { | ||||
|       id: 2, | ||||
|       name: 'Custom Field 2', | ||||
|       data_type: CustomFieldDataType.Integer, | ||||
|     }, | ||||
|   ] | ||||
| 
 | ||||
|   service['setupSortFields']() | ||||
|   expect(service.sortFields).toEqual([ | ||||
|     ...DOCUMENT_SORT_FIELDS, | ||||
|     { | ||||
|       field: 'custom_field_1', | ||||
|       name: 'Custom Field 1', | ||||
|     }, | ||||
|     { | ||||
|       field: 'custom_field_2', | ||||
|       name: 'Custom Field 2', | ||||
|     }, | ||||
|   ]) | ||||
| }) | ||||
| 
 | ||||
| afterEach(() => { | ||||
|   subscription?.unsubscribe() | ||||
|   httpTestingController.verify() | ||||
|  | ||||
| @ -3,6 +3,7 @@ import { Injectable } from '@angular/core' | ||||
| import { Observable } from 'rxjs' | ||||
| import { map, tap } from 'rxjs/operators' | ||||
| import { AuditLogEntry } from 'src/app/data/auditlog-entry' | ||||
| import { CustomField } from 'src/app/data/custom-field' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
| @ -22,6 +23,7 @@ import { | ||||
| import { SettingsService } from '../settings.service' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
| import { CorrespondentService } from './correspondent.service' | ||||
| import { CustomFieldsService } from './custom-fields.service' | ||||
| import { DocumentTypeService } from './document-type.service' | ||||
| import { StoragePathService } from './storage-path.service' | ||||
| import { TagService } from './tag.service' | ||||
| @ -55,6 +57,8 @@ export class DocumentService extends AbstractPaperlessService<Document> { | ||||
|     return this._sortFieldsFullText | ||||
|   } | ||||
| 
 | ||||
|   private customFields: CustomField[] = [] | ||||
| 
 | ||||
|   constructor( | ||||
|     http: HttpClient, | ||||
|     private correspondentService: CorrespondentService, | ||||
| @ -62,14 +66,40 @@ export class DocumentService extends AbstractPaperlessService<Document> { | ||||
|     private tagService: TagService, | ||||
|     private storagePathService: StoragePathService, | ||||
|     private permissionsService: PermissionsService, | ||||
|     private settingsService: SettingsService | ||||
|     private settingsService: SettingsService, | ||||
|     private customFieldService: CustomFieldsService | ||||
|   ) { | ||||
|     super(http, 'documents') | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.CustomField | ||||
|       ) | ||||
|     ) { | ||||
|       this.customFieldService.listAll().subscribe((fields) => { | ||||
|         this.customFields = fields.results | ||||
|         this.setupSortFields() | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     this.setupSortFields() | ||||
|   } | ||||
| 
 | ||||
|   private setupSortFields() { | ||||
|     this._sortFields = [...DOCUMENT_SORT_FIELDS] | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.CustomField | ||||
|       ) | ||||
|     ) { | ||||
|       this.customFields.forEach((field) => { | ||||
|         this._sortFields.push({ | ||||
|           field: `custom_field_${field.id}`, | ||||
|           name: field.name, | ||||
|         }) | ||||
|       }) | ||||
|     } | ||||
|     let excludes = [] | ||||
|     if ( | ||||
|       !this.permissionsService.currentUserCan( | ||||
|  | ||||
| @ -6,10 +6,17 @@ from collections.abc import Callable | ||||
| from contextlib import contextmanager | ||||
| 
 | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.db.models import Case | ||||
| from django.db.models import CharField | ||||
| from django.db.models import Count | ||||
| from django.db.models import Exists | ||||
| from django.db.models import IntegerField | ||||
| from django.db.models import OuterRef | ||||
| from django.db.models import Q | ||||
| from django.db.models import Subquery | ||||
| from django.db.models import Sum | ||||
| from django.db.models import Value | ||||
| from django.db.models import When | ||||
| from django.db.models.functions import Cast | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from django_filters.rest_framework import BooleanFilter | ||||
| @ -18,6 +25,7 @@ from django_filters.rest_framework import FilterSet | ||||
| from guardian.utils import get_group_obj_perms_model | ||||
| from guardian.utils import get_user_obj_perms_model | ||||
| from rest_framework import serializers | ||||
| from rest_framework.filters import OrderingFilter | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
| 
 | ||||
| from documents.models import Correspondent | ||||
| @ -760,3 +768,141 @@ class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter): | ||||
|         objects_owned = queryset.filter(owner=request.user) | ||||
|         objects_unowned = queryset.filter(owner__isnull=True) | ||||
|         return objects_owned | objects_unowned | ||||
| 
 | ||||
| 
 | ||||
| class DocumentsOrderingFilter(OrderingFilter): | ||||
|     field_name = "ordering" | ||||
|     prefix = "custom_field_" | ||||
| 
 | ||||
|     def filter_queryset(self, request, queryset, view): | ||||
|         param = request.query_params.get("ordering") | ||||
|         if param and self.prefix in param: | ||||
|             custom_field_id = int(param.split(self.prefix)[1]) | ||||
|             try: | ||||
|                 field = CustomField.objects.get(pk=custom_field_id) | ||||
|             except CustomField.DoesNotExist: | ||||
|                 raise serializers.ValidationError( | ||||
|                     {self.prefix + str(custom_field_id): [_("Custom field not found")]}, | ||||
|                 ) | ||||
| 
 | ||||
|             annotation = None | ||||
|             match field.data_type: | ||||
|                 case CustomField.FieldDataType.STRING: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_text")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.INT: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_int")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.FLOAT: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_float")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.DATE: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_date")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.MONETARY: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_monetary_amount")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.SELECT: | ||||
|                     # Select options are a little more complicated since the value is the id of the option, not | ||||
|                     # the label. Additionally, to support sqlite we can't use StringAgg, so we need to create a | ||||
|                     # case statement for each option, setting the value to the index of the option in a list | ||||
|                     # sorted by label, and then summing the results to give a single value for the annotation | ||||
| 
 | ||||
|                     select_options = sorted( | ||||
|                         field.extra_data.get("select_options", []), | ||||
|                         key=lambda x: x.get("label"), | ||||
|                     ) | ||||
|                     whens = [ | ||||
|                         When( | ||||
|                             custom_fields__field_id=custom_field_id, | ||||
|                             custom_fields__value_select=option.get("id"), | ||||
|                             then=Value(idx, output_field=IntegerField()), | ||||
|                         ) | ||||
|                         for idx, option in enumerate(select_options) | ||||
|                     ] | ||||
|                     whens.append( | ||||
|                         When( | ||||
|                             custom_fields__field_id=custom_field_id, | ||||
|                             custom_fields__value_select__isnull=True, | ||||
|                             then=Value( | ||||
|                                 len(select_options), | ||||
|                                 output_field=IntegerField(), | ||||
|                             ), | ||||
|                         ), | ||||
|                     ) | ||||
|                     annotation = Sum( | ||||
|                         Case( | ||||
|                             *whens, | ||||
|                             default=Value(0), | ||||
|                             output_field=IntegerField(), | ||||
|                         ), | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.DOCUMENTLINK: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_document_ids")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.URL: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_url")[:1], | ||||
|                     ) | ||||
|                 case CustomField.FieldDataType.BOOL: | ||||
|                     annotation = Subquery( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ).values("value_bool")[:1], | ||||
|                     ) | ||||
| 
 | ||||
|             if not annotation: | ||||
|                 # Only happens if a new data type is added and not handled here | ||||
|                 raise ValueError("Invalid custom field data type") | ||||
| 
 | ||||
|             queryset = ( | ||||
|                 queryset.annotate( | ||||
|                     # We need to annotate the queryset with the custom field value | ||||
|                     custom_field_value=annotation, | ||||
|                     # We also need to annotate the queryset with a boolean for sorting whether the field exists | ||||
|                     has_field=Exists( | ||||
|                         CustomFieldInstance.objects.filter( | ||||
|                             document_id=OuterRef("id"), | ||||
|                             field_id=custom_field_id, | ||||
|                         ), | ||||
|                     ), | ||||
|                 ) | ||||
|                 .order_by( | ||||
|                     "-has_field", | ||||
|                     param.replace( | ||||
|                         self.prefix + str(custom_field_id), | ||||
|                         "custom_field_value", | ||||
|                     ), | ||||
|                 ) | ||||
|                 .distinct() | ||||
|             ) | ||||
| 
 | ||||
|         return super().filter_queryset(request, queryset, view) | ||||
|  | ||||
| @ -5,6 +5,7 @@ import tempfile | ||||
| import uuid | ||||
| import zoneinfo | ||||
| from binascii import hexlify | ||||
| from datetime import date | ||||
| from datetime import timedelta | ||||
| from pathlib import Path | ||||
| from unittest import mock | ||||
| @ -2762,3 +2763,184 @@ class TestDocumentApiV2(DirectoriesMixin, APITestCase): | ||||
|             self.client.get(f"/api/tags/{t.id}/", format="json").data["text_color"], | ||||
|             "#000000", | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| class TestDocumentApiCustomFieldsSorting(DirectoriesMixin, APITestCase): | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
| 
 | ||||
|         self.user = User.objects.create_superuser(username="temp_admin") | ||||
|         self.client.force_authenticate(user=self.user) | ||||
| 
 | ||||
|         self.doc1 = Document.objects.create( | ||||
|             title="none1", | ||||
|             checksum="A", | ||||
|             mime_type="application/pdf", | ||||
|         ) | ||||
|         self.doc2 = Document.objects.create( | ||||
|             title="none2", | ||||
|             checksum="B", | ||||
|             mime_type="application/pdf", | ||||
|         ) | ||||
|         self.doc3 = Document.objects.create( | ||||
|             title="none3", | ||||
|             checksum="C", | ||||
|             mime_type="application/pdf", | ||||
|         ) | ||||
| 
 | ||||
|         cache.clear() | ||||
| 
 | ||||
|     def test_document_custom_fields_sorting(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Documents with custom fields | ||||
|         WHEN: | ||||
|             - API request for document filtering with custom field sorting | ||||
|         THEN: | ||||
|             - Documents are sorted by custom field values | ||||
|         """ | ||||
|         values = { | ||||
|             CustomField.FieldDataType.STRING: { | ||||
|                 "values": ["foo", "bar", "baz"], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.STRING | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.INT: { | ||||
|                 "values": [3, 1, 2], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.INT | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.FLOAT: { | ||||
|                 "values": [3.3, 1.1, 2.2], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.FLOAT | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.BOOL: { | ||||
|                 "values": [True, False, False], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.BOOL | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.DATE: { | ||||
|                 "values": [date(2021, 1, 3), date(2021, 1, 1), date(2021, 1, 2)], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.DATE | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.URL: { | ||||
|                 "values": [ | ||||
|                     "http://example.org", | ||||
|                     "http://example.com", | ||||
|                     "http://example.net", | ||||
|                 ], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.URL | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.MONETARY: { | ||||
|                 "values": ["USD789.00", "USD123.00", "USD456.00"], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.MONETARY | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.DOCUMENTLINK: { | ||||
|                 "values": [self.doc3.pk, self.doc1.pk, self.doc2.pk], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.DOCUMENTLINK | ||||
|                 ], | ||||
|             }, | ||||
|             CustomField.FieldDataType.SELECT: { | ||||
|                 "values": ["ghi-789", "abc-123", "def-456"], | ||||
|                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||
|                     CustomField.FieldDataType.SELECT | ||||
|                 ], | ||||
|                 "extra_data": { | ||||
|                     "select_options": [ | ||||
|                         {"label": "Option 1", "id": "abc-123"}, | ||||
|                         {"label": "Option 2", "id": "def-456"}, | ||||
|                         {"label": "Option 3", "id": "ghi-789"}, | ||||
|                     ], | ||||
|                 }, | ||||
|             }, | ||||
|         } | ||||
| 
 | ||||
|         for data_type, data in values.items(): | ||||
|             CustomField.objects.all().delete() | ||||
|             CustomFieldInstance.objects.all().delete() | ||||
|             custom_field = CustomField.objects.create( | ||||
|                 name=f"custom field {data_type}", | ||||
|                 data_type=data_type, | ||||
|                 extra_data=data.get("extra_data", {}), | ||||
|             ) | ||||
|             for i, value in enumerate(data["values"]): | ||||
|                 CustomFieldInstance.objects.create( | ||||
|                     document=[self.doc1, self.doc2, self.doc3][i], | ||||
|                     field=custom_field, | ||||
|                     **{data["field_name"]: value}, | ||||
|                 ) | ||||
|             response = self.client.get( | ||||
|                 f"/api/documents/?ordering=custom_field_{custom_field.pk}", | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|             results = response.data["results"] | ||||
|             self.assertEqual(len(results), 3) | ||||
|             self.assertEqual( | ||||
|                 [results[0]["id"], results[1]["id"], results[2]["id"]], | ||||
|                 [self.doc2.id, self.doc3.id, self.doc1.id], | ||||
|             ) | ||||
| 
 | ||||
|             response = self.client.get( | ||||
|                 f"/api/documents/?ordering=-custom_field_{custom_field.pk}", | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|             results = response.data["results"] | ||||
|             self.assertEqual(len(results), 3) | ||||
|             if data_type == CustomField.FieldDataType.BOOL: | ||||
|                 # just check the first one for bools, as the rest are the same | ||||
|                 self.assertEqual( | ||||
|                     [results[0]["id"]], | ||||
|                     [self.doc1.id], | ||||
|                 ) | ||||
|             else: | ||||
|                 self.assertEqual( | ||||
|                     [results[0]["id"], results[1]["id"], results[2]["id"]], | ||||
|                     [self.doc1.id, self.doc3.id, self.doc2.id], | ||||
|                 ) | ||||
| 
 | ||||
|     def test_document_custom_fields_sorting_invalid(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Documents with custom fields | ||||
|         WHEN: | ||||
|             - API request for document filtering with invalid custom field sorting | ||||
|         THEN: | ||||
|             - 400 is returned | ||||
|         """ | ||||
| 
 | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?ordering=custom_field_999", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
| 
 | ||||
|     def test_document_custom_fields_sorting_invalid_data_type(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Documents with custom fields | ||||
|         WHEN: | ||||
|             - API request for document filtering with a custom field sorting with a new (unhandled) data type | ||||
|         THEN: | ||||
|             - Error is raised | ||||
|         """ | ||||
| 
 | ||||
|         custom_field = CustomField.objects.create( | ||||
|             name="custom field", | ||||
|             data_type="foo", | ||||
|         ) | ||||
| 
 | ||||
|         with self.assertRaises(ValueError): | ||||
|             self.client.get( | ||||
|                 f"/api/documents/?ordering=custom_field_{custom_field.pk}", | ||||
|             ) | ||||
|  | ||||
| @ -96,6 +96,7 @@ from documents.data_models import DocumentSource | ||||
| from documents.filters import CorrespondentFilterSet | ||||
| from documents.filters import CustomFieldFilterSet | ||||
| from documents.filters import DocumentFilterSet | ||||
| from documents.filters import DocumentsOrderingFilter | ||||
| from documents.filters import DocumentTypeFilterSet | ||||
| from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | ||||
| from documents.filters import ObjectOwnedPermissionsFilter | ||||
| @ -350,7 +351,7 @@ class DocumentViewSet( | ||||
|     filter_backends = ( | ||||
|         DjangoFilterBackend, | ||||
|         SearchFilter, | ||||
|         OrderingFilter, | ||||
|         DocumentsOrderingFilter, | ||||
|         ObjectOwnedOrGrantedPermissionsFilter, | ||||
|     ) | ||||
|     filterset_class = DocumentFilterSet | ||||
| @ -367,6 +368,7 @@ class DocumentViewSet( | ||||
|         "num_notes", | ||||
|         "owner", | ||||
|         "page_count", | ||||
|         "custom_field_", | ||||
|     ) | ||||
| 
 | ||||
|     def get_queryset(self): | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user