]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: custom field sorting (#8494)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 30 Dec 2024 18:18:34 +0000 (10:18 -0800)
committerGitHub <noreply@github.com>
Mon, 30 Dec 2024 18:18:34 +0000 (18:18 +0000)
src-ui/messages.xlf
src-ui/src/app/components/document-list/document-list.component.html
src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts
src-ui/src/app/services/document-list-view.service.spec.ts
src-ui/src/app/services/document-list-view.service.ts
src-ui/src/app/services/rest/document.service.spec.ts
src-ui/src/app/services/rest/document.service.ts
src/documents/filters.py
src/documents/tests/test_api_documents.py
src/documents/views.py

index 7abb95823f45ff120b8d786a4e9772e654372b0a..314910f5d575575f44113b594c7d205ff97398fe 100644 (file)
         </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">
         </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">
         </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">
         </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">
           <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>
         <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>
index fdf3e24ed7613970d7096eccb273a43e325a02f2..5ad3aa6c70842c8556ea39daf6b28c0b1ef55228 100644 (file)
                   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>
index 9ff50f1bca74f5e1e1ff262e595ec9693ce5e768..9372c94fffb05355401a3f0cde7a6d149bf38441 100644 (file)
@@ -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(),
index 2ef084b1711959919a9818c5a011f409d933a7f1..d93acc5219b9d62a9e4a5386133838afbfaeed30 100644 (file)
@@ -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()
index e15c11d3ac8a7c93e7bb82b9243d99fe0adffce0..893bfca913b841ee342866b889c3a4319dccd298 100644 (file)
@@ -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(', ')
index 72610abee199ff9ff4fd82cf17d6299445c5381f..dd4df41f84a4275d814fdd0820221320a570fdee 100644 (file)
@@ -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()
index a703f0388c852c746959d9641c93ed55aa194754..d9ae0456359ea1c4a69fdb181fcd03d6d6a96fc0 100644 (file)
@@ -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(
index 237973b6f049d1675996d75986d548f45302a69d..185ba7b6fbe7ac7fe4f6f978df5e603960cfefdd 100644 (file)
@@ -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)
index 8307d6c4ceade4e9d0b07b607fcef7d2311fb201..ea5227c8af942e1e5638564d37bb5cad1877446c 100644 (file)
@@ -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}",
+            )
index 6d2c8cbd8a9b943d390c440455f4136649857433..4e2e4a8bf04cf6908ccb0e65e456b8e94b00d905 100644 (file)
@@ -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):