Explorar o código

#22231 - Add nulls-first parameter for custom field ordering

Arthur hai 2 semanas
pai
achega
b74cff2f7e

+ 4 - 0
docs/models/extras/customfield.md

@@ -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 (enabled, the default) or after (disabled) objects that have a value.
+
 ### Minimum Value
 
 For numeric custom fields only. The minimum valid value (optional).

+ 1 - 1
netbox/extras/api/serializers_/customfields.py

@@ -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',
         ]

+ 2 - 2
netbox/extras/filtersets.py

@@ -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):

+ 6 - 1
netbox/extras/forms/bulk_edit.py

@@ -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')

+ 1 - 1
netbox/extras/forms/bulk_import.py

@@ -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',
         )
 
 

+ 8 - 1
netbox/extras/forms/filtersets.py

@@ -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

+ 2 - 1
netbox/extras/forms/model_forms.py

@@ -89,7 +89,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')
         ),
     )
 

+ 1 - 0
netbox/extras/graphql/filters.py

@@ -186,6 +186,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()
 
 

+ 16 - 0
netbox/extras/migrations/0140_custom_field_nulls_first.py

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

+ 6 - 0
netbox/extras/models/customfields.py

@@ -259,6 +259,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
@@ -270,6 +275,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:

+ 6 - 2
netbox/extras/tables/tables.py

@@ -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',

+ 3 - 1
netbox/extras/tests/test_api.py

@@ -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',

+ 34 - 0
netbox/extras/tests/test_customfields.py

@@ -15,6 +15,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
 
@@ -68,6 +69,39 @@ 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])
+
     def test_longtext_field(self):
         value = 'A' * 256
 

+ 10 - 2
netbox/extras/tests/test_filtersets.py

@@ -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()

+ 24 - 1
netbox/netbox/tables/columns.py

@@ -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
@@ -523,6 +524,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'):