diff --git a/netbox/utilities/forms/widgets/modifiers.py b/netbox/utilities/forms/widgets/modifiers.py index 26e60dec2..00a423a18 100644 --- a/netbox/utilities/forms/widgets/modifiers.py +++ b/netbox/utilities/forms/widgets/modifiers.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple @@ -101,21 +102,27 @@ class FilterModifierWidget(forms.Widget): if isinstance(self.original_widget, (APISelect, APISelectMultiple)): original_choices = self.original_widget.choices - # Only keep selected choices to preserve current selection in HTML + # Only keep selected choices to preserve the current selection in HTML if value: values = value if isinstance(value, (list, tuple)) else [value] if hasattr(original_choices, 'queryset'): - queryset = original_choices.queryset - selected_objects = queryset.filter(pk__in=values) - # Build minimal choice list with just the selected values - self.original_widget.choices = [ - (obj.pk, str(obj)) for obj in selected_objects - ] + # Extract valid PKs (exclude special null choice string) + pk_values = [v for v in values if v != settings.FILTERS_NULL_CHOICE_VALUE] + + # Build a minimal choice list with just the selected values + choices = [] + if pk_values: + selected_objects = original_choices.queryset.filter(pk__in=pk_values) + choices = [(obj.pk, str(obj)) for obj in selected_objects] + + # Re-add the "None" option if it was selected via the null choice value + if settings.FILTERS_NULL_CHOICE_VALUE in values: + choices.append((settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL)) + + self.original_widget.choices = choices else: - self.original_widget.choices = [ - choice for choice in original_choices if choice[0] in values - ] + self.original_widget.choices = [choice for choice in original_choices if choice[0] in values] else: # No selection - render empty select element self.original_widget.choices = [] diff --git a/netbox/utilities/tests/test_filter_modifiers.py b/netbox/utilities/tests/test_filter_modifiers.py index 70352e7d8..9adabde62 100644 --- a/netbox/utilities/tests/test_filter_modifiers.py +++ b/netbox/utilities/tests/test_filter_modifiers.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.db import models from django.http import QueryDict from django.template import Context @@ -14,6 +15,7 @@ from utilities.forms.fields import TagFilterField from utilities.forms.mixins import FilterModifierMixin from utilities.forms.widgets import FilterModifierWidget from utilities.templatetags.helpers import applied_filters +from tenancy.models import Tenant # Test model for FilterModifierMixin tests @@ -99,6 +101,51 @@ class FilterModifierWidgetTest(TestCase): self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL self.assertEqual(context['widget']['current_value'], 'test') + def test_get_context_handles_null_selection(self): + """Widget should preserve the 'null' choice when rendering.""" + + null_value = settings.FILTERS_NULL_CHOICE_VALUE + null_label = settings.FILTERS_NULL_CHOICE_LABEL + + # Simulate a query for objects with no tenant assigned (?tenant_id=null) + query_params = QueryDict(f'tenant_id={null_value}') + form = DeviceFilterForm(query_params) + + # Rendering the field triggers FilterModifierWidget.get_context() + try: + html = form['tenant_id'].as_widget() + except ValueError as e: + # ValueError: Field 'id' expected a number but got 'null' + self.fail(f"FilterModifierWidget raised ValueError on 'null' selection: {e}") + + # Verify the "None" option is rendered so user selection is preserved in the UI + self.assertIn(f'value="{null_value}"', html) + self.assertIn(null_label, html) + + def test_get_context_handles_mixed_selection(self): + """Widget should preserve both real objects and the 'null' choice together.""" + + null_value = settings.FILTERS_NULL_CHOICE_VALUE + + # Create a tenant to simulate a real object + tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a') + + # Simulate a selection containing both a real PK and the null sentinel + query_params = QueryDict('', mutable=True) + query_params.setlist('tenant_id', [str(tenant.pk), null_value]) + form = DeviceFilterForm(query_params) + + # Rendering the field triggers FilterModifierWidget.get_context() + try: + html = form['tenant_id'].as_widget() + except ValueError as e: + # ValueError: Field 'id' expected a number but got 'null' + self.fail(f"FilterModifierWidget raised ValueError on 'null' selection: {e}") + + # Verify both the real object and the null option are present in the output + self.assertIn(f'value="{tenant.pk}"', html) + self.assertIn(f'value="{null_value}"', html) + def test_widget_renders_modifier_dropdown_and_input(self): """Widget should render modifier dropdown alongside original input.""" widget = FilterModifierWidget(