#22231 - Add nulls-first parameter for custom field ordering (#22476)

This commit is contained in:
Arthur Hanson 2026-07-08 11:45:53 -07:00 committed by GitHub
parent 800db5727f
commit c3bc1fb04a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 136 additions and 13 deletions

View File

@ -109,6 +109,10 @@ Choice sets may optionally define colors for individual values. Colored choices
If enabled, values from this field will be automatically pre-populated when cloning existing objects.
### Nulls First
When ordering objects by this custom field, controls whether objects with no value (null) are sorted before or after objects that have a value. This option is enabled by default.
### Minimum Value
For numeric custom fields only. The minimum valid value (optional).

View File

@ -68,7 +68,7 @@ class CustomFieldSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedMod
fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type',
'name', 'label', 'group_name', 'description', 'required', 'unique', 'search_weight', 'filter_logic',
'ui_visible', 'ui_editable', 'is_cloneable', 'default', 'related_object_filter', 'weight',
'ui_visible', 'ui_editable', 'is_cloneable', 'nulls_first', 'default', 'related_object_filter', 'weight',
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', 'choice_set',
'owner', 'comments', 'created', 'last_updated',
]

View File

@ -175,8 +175,8 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
model = CustomField
fields = (
'id', 'name', 'label', 'group_name', 'required', 'unique', 'search_weight', 'filter_logic', 'ui_visible',
'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum',
'validation_regex',
'ui_editable', 'weight', 'is_cloneable', 'nulls_first', 'description', 'validation_minimum',
'validation_maximum', 'validation_regex',
)
def search(self, queryset, name, value):

View File

@ -76,6 +76,11 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
nulls_first = forms.NullBooleanField(
label=_('Nulls first'),
required=False,
widget=BulkEditNullBooleanSelect()
)
validation_minimum = forms.DecimalField(
label=_('Minimum value'),
required=False,
@ -96,7 +101,7 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
fieldsets = (
FieldSet('group_name', 'description', 'weight', 'required', 'unique', 'choice_set', name=_('Attributes')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', 'nulls_first', name=_('Behavior')),
FieldSet(
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema',
name=_('Validation')

View File

@ -81,7 +81,7 @@ class CustomFieldImportForm(OwnerCSVMixin, CSVModelForm):
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'unique',
'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'validation_schema', 'ui_visible', 'ui_editable',
'is_cloneable', 'owner', 'comments',
'is_cloneable', 'nulls_first', 'owner', 'comments',
)

View File

@ -47,7 +47,7 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', name=_('Attributes')),
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', 'nulls_first', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
@ -110,6 +110,13 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
nulls_first = forms.NullBooleanField(
label=_('Nulls first'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
validation_minimum = forms.DecimalField(
label=_('Minimum value'),
required=False

View File

@ -119,7 +119,8 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
name=_('Custom Field')
),
FieldSet(
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', 'nulls_first',
name=_('Behavior')
),
)

View File

@ -193,6 +193,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
strawberry_django.filter_field()
)
is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
nulls_first: FilterLookup[bool] | None = strawberry_django.filter_field()
comments: StrFilterLookup | None = strawberry_django.filter_field()

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("extras", "0140_imageattachment_image_size"),
]
operations = [
migrations.AddField(
model_name="customfield",
name="nulls_first",
field=models.BooleanField(default=True),
),
]

View File

@ -260,6 +260,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
verbose_name=_('is cloneable'),
help_text=_('Replicate this value when cloning objects')
)
nulls_first = models.BooleanField(
default=True,
verbose_name=_('nulls first'),
help_text=_('Sort null values before non-null values when ordering by this field')
)
comments = models.TextField(
verbose_name=_('comments'),
blank=True
@ -271,6 +276,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'unique',
'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'validation_schema', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
'nulls_first',
)
class Meta:

View File

@ -112,6 +112,10 @@ class CustomFieldTable(NetBoxTable):
verbose_name=_('Is Cloneable'),
false_mark=None
)
nulls_first = columns.BooleanColumn(
verbose_name=_('Nulls First'),
false_mark=None
)
validation_minimum = tables.Column(
verbose_name=_('Minimum Value'),
)
@ -135,8 +139,8 @@ class CustomFieldTable(NetBoxTable):
fields = (
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
'unique', 'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
'is_cloneable', 'weight', 'choice_set', 'choices', 'validation_minimum', 'validation_maximum',
'validation_regex', 'validation_schema', 'comments', 'created', 'last_updated',
'is_cloneable', 'nulls_first', 'weight', 'choice_set', 'choices', 'validation_minimum',
'validation_maximum', 'validation_regex', 'validation_schema', 'comments', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'unique', 'description',

View File

@ -175,6 +175,7 @@ class CustomFieldTestCase(APIViewTestCases.APIViewTestCase):
]
bulk_update_data = {
'description': 'New description',
'nulls_first': False,
}
update_data = {
'object_types': ['dcim.device'],
@ -193,7 +194,8 @@ class CustomFieldTestCase(APIViewTestCases.APIViewTestCase):
),
CustomField(
name='cf2',
type='integer'
type='integer',
nulls_first=False
),
CustomField(
name='cf3',

View File

@ -16,6 +16,7 @@ from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from netbox.tables.columns import CustomFieldColumn
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@ -69,6 +70,50 @@ class CustomFieldTestCase(TestCase):
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_nulls_first_ordering(self):
"""
Verify that CustomFieldColumn.order() places null values first or last according to the
custom field's nulls_first attribute.
"""
cf = CustomField.objects.create(
name='order_field',
type=CustomFieldTypeChoices.TYPE_INTEGER,
required=False
)
cf.object_types.set([self.object_type])
# Assign values to two of the three sites, leaving the third null
site_a = Site.objects.get(name='Site A')
site_a.custom_field_data[cf.name] = 1
site_a.save()
site_b = Site.objects.get(name='Site B')
site_b.custom_field_data[cf.name] = 2
site_b.save()
site_c = Site.objects.get(name='Site C') # no value (null)
column = CustomFieldColumn(cf)
# nulls_first=True (default): null value sorts before populated values when ascending
cf.nulls_first = True
queryset, _ = column.order(Site.objects.all(), is_descending=False)
self.assertEqual(list(queryset), [site_c, site_a, site_b])
# nulls_first=False: null value sorts after populated values when ascending
cf.nulls_first = False
queryset, _ = column.order(Site.objects.all(), is_descending=False)
self.assertEqual(list(queryset), [site_a, site_b, site_c])
# Null placement is independent of sort direction: nulls_first=True keeps the null value
# first even when sorting descending
cf.nulls_first = True
queryset, _ = column.order(Site.objects.all(), is_descending=True)
self.assertEqual(list(queryset), [site_c, site_b, site_a])
# nulls_first=False keeps the null value last even when sorting descending
cf.nulls_first = False
queryset, _ = column.order(Site.objects.all(), is_descending=True)
self.assertEqual(list(queryset), [site_b, site_a, site_c])
def test_longtext_field(self):
value = 'A' * 256

View File

@ -51,7 +51,8 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
ui_visible=CustomFieldUIVisibleChoices.IF_SET,
ui_editable=CustomFieldUIEditableChoices.NO,
description='foobar2'
description='foobar2',
nulls_first=False
),
CustomField(
name='Custom Field 3',
@ -61,7 +62,8 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
ui_editable=CustomFieldUIEditableChoices.HIDDEN,
description='foobar3'
description='foobar3',
nulls_first=False
),
CustomField(
name='Custom Field 4',
@ -151,6 +153,12 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_nulls_first(self):
params = {'nulls_first': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'nulls_first': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CustomFieldChoiceSet.objects.all()

View File

@ -154,6 +154,7 @@ class CustomFieldBehaviorPanel(panels.ObjectAttributesPanel):
weight = attrs.NumericAttr('weight', label=_('Display weight'))
ui_visible = attrs.ChoiceAttr('ui_visible', label=_('UI visible'))
ui_editable = attrs.ChoiceAttr('ui_editable', label=_('UI editable'))
nulls_first = attrs.BooleanAttr('nulls_first', label=_('Nulls first'))
class CustomFieldValidationPanel(panels.ObjectAttributesPanel):

View File

@ -6,7 +6,8 @@ import django_tables2 as tables
from django.conf import settings
from django.contrib.auth.context_processors import auth
from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField
from django.db.models import Case, DateField, DateTimeField, F, IntegerField, Value, When
from django.db.models.fields.json import KeyTextTransform
from django.template import Context, Template
from django.urls import reverse
from django.utils.dateparse import parse_date
@ -527,6 +528,28 @@ class CustomFieldColumn(tables.Column):
super().__init__(*args, **kwargs)
def order(self, queryset, is_descending):
# Order by the underlying JSON value, honoring the custom field's null placement preference.
# A missing key or a JSON null value is extracted as SQL NULL via the ->> (text) operator,
# whereas the -> (JSONB) operator used for value ordering treats JSON null as a sortable value.
# We therefore annotate an explicit rank to control null placement independently of JSONB sorting.
name = self.customfield.name
text_value = f'_cf_{name}_text'
null_rank = f'_cf_{name}_nullrank'
null_sort, value_sort = (0, 1) if self.customfield.nulls_first else (1, 0)
queryset = queryset.annotate(**{
text_value: KeyTextTransform(name, 'custom_field_data'),
}).annotate(**{
null_rank: Case(
When(**{f'{text_value}__isnull': True}, then=Value(null_sort)),
default=Value(value_sort),
output_field=IntegerField(),
),
})
value = F(f'custom_field_data__{name}')
ordering = (null_rank, value.desc() if is_descending else value.asc())
return queryset.order_by(*ordering), True
@staticmethod
def _linkify_item(item):
if hasattr(item, 'get_absolute_url'):