diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index 7abb95823..314910f5d 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -5942,7 +5942,7 @@
src/app/components/document-list/document-list.component.html
- 289
+ 294
@@ -5957,7 +5957,7 @@
src/app/components/document-list/document-list.component.html
- 329
+ 334
@@ -5972,7 +5972,7 @@
src/app/components/document-list/document-list.component.html
- 336
+ 341
@@ -7209,7 +7209,7 @@
src/app/components/document-list/document-list.component.html
- 305
+ 310
@@ -7573,25 +7573,32 @@
261,263
+
+ Sort by
+
+ src/app/components/document-list/document-list.component.html
+ 268,269
+
+
Edit document
src/app/components/document-list/document-list.component.html
- 297
+ 302
Preview document
src/app/components/document-list/document-list.component.html
- 298
+ 303
Yes
src/app/components/document-list/document-list.component.html
- 357
+ 362
src/app/pipes/yes-no.pipe.ts
@@ -7602,7 +7609,7 @@
No
src/app/components/document-list/document-list.component.html
- 357
+ 362
src/app/pipes/yes-no.pipe.ts
diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html
index fdf3e24ed..5ad3aa6c7 100644
--- a/src-ui/src/app/components/document-list/document-list.component.html
+++ b/src-ui/src/app/components/document-list/document-list.component.html
@@ -262,9 +262,14 @@
Shared
}
- @for (field of activeDisplayCustomFields; track field) {
-
- {{getDisplayCustomFieldTitle(field)}}
+ @for (field_id of activeDisplayCustomFields; track field_id) {
+ |
+ {{getDisplayCustomFieldTitle(field_id)}}
|
}
diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts
index 9ff50f1bc..9372c94ff 100644
--- a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts
+++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts
@@ -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(),
diff --git a/src-ui/src/app/services/document-list-view.service.spec.ts b/src-ui/src/app/services/document-list-view.service.spec.ts
index 2ef084b17..d93acc521 100644
--- a/src-ui/src/app/services/document-list-view.service.spec.ts
+++ b/src-ui/src/app/services/document-list-view.service.spec.ts
@@ -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()
diff --git a/src-ui/src/app/services/document-list-view.service.ts b/src-ui/src/app/services/document-list-view.service.ts
index e15c11d3a..893bfca91 100644
--- a/src-ui/src/app/services/document-list-view.service.ts
+++ b/src-ui/src/app/services/document-list-view.service.ts
@@ -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 }
errorMessage = Object.keys(error.error)
.map((fieldName) => {
+ const fieldNameBase = fieldName.split('__')[0]
const fieldError: Array = 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(', ')
diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts
index 72610abee..dd4df41f8 100644
--- a/src-ui/src/app/services/rest/document.service.spec.ts
+++ b/src-ui/src/app/services/rest/document.service.spec.ts
@@ -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()
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index a703f0388..d9ae04563 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -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 {
return this._sortFieldsFullText
}
+ private customFields: CustomField[] = []
+
constructor(
http: HttpClient,
private correspondentService: CorrespondentService,
@@ -62,14 +66,40 @@ export class DocumentService extends AbstractPaperlessService {
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(
diff --git a/src/documents/filters.py b/src/documents/filters.py
index 237973b6f..185ba7b6f 100644
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -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)
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 8307d6c4c..ea5227c8a 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -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}",
+ )
diff --git a/src/documents/views.py b/src/documents/views.py
index 6d2c8cbd8..4e2e4a8bf 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -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):