Browse Source

Fixes #22359: Pass static query_params to the object selector modal (#23320)

Graham 2 days ago
parent
commit
5636d27079

+ 67 - 0
netbox/netbox/tests/test_htmx.py

@@ -0,0 +1,67 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
+
+
+class ObjectSelectorViewTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = get_user_model().objects.create_superuser(username='testuser', password='testpass')
+
+        cls.site_1 = Site.objects.create(name='Office1', slug='office1')
+        cls.site_2 = Site.objects.create(name='office2', slug='office2')
+
+        manufacturer = Manufacturer.objects.create(name='Test Mfr', slug='test-mfr')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Type', slug='test-type')
+        role = DeviceRole.objects.create(name='Test Role', slug='test-role')
+
+        cls.device_1 = Device.objects.create(name='Megatron 1', site=cls.site_1, device_type=device_type, role=role)
+        cls.device_2 = Device.objects.create(name='Megatron 2', site=cls.site_2, device_type=device_type, role=role)
+
+    def setUp(self):
+        self.client.force_login(self.user)
+
+    def get(self, **params):
+        params = {'_model': 'dcim.device', 'target': 'id_select_vm', **params}
+        return self.client.get(reverse('htmx_object_selector'), params)
+
+    def test_modal_includes_hidden_input_for_static_param(self):
+        response = self.get(_static_site_id=self.site_1.pk)
+        self.assertContains(response, f'type="hidden" name="_static_site_id" value="{self.site_1.pk}"')
+
+    def test_modal_includes_hidden_input_for_each_value(self):
+        response = self.get(_static_site_id=[self.site_1.pk, self.site_2.pk])
+        self.assertContains(response, f'type="hidden" name="_static_site_id" value="{self.site_1.pk}"')
+        self.assertContains(response, f'type="hidden" name="_static_site_id" value="{self.site_2.pk}"')
+
+    def test_modal_does_not_duplicate_routing_params_as_hidden_inputs(self):
+        response = self.get(_static_site_id=self.site_1.pk)
+        self.assertNotContains(response, 'type="hidden" name="_model"')
+        self.assertNotContains(response, 'type="hidden" name="target"')
+
+    def test_modal_without_static_params_has_no_site_id_hidden_input(self):
+        response = self.get()
+        self.assertNotContains(response, 'type="hidden" name="_static_site_id"')
+
+    def test_search_filters_by_site(self):
+        response = self.get(_search='true', _static_site_id=self.site_1.pk)
+        self.assertContains(response, self.device_1.name)
+        self.assertNotContains(response, self.device_2.name)
+
+    def test_search_user_filter_cannot_widen_static_constraint(self):
+        # The visible Site filter picks site 2, but the field is constrained to site 1
+        response = self.get(_search='true', _static_site_id=self.site_1.pk, site_id=self.site_2.pk)
+        self.assertNotContains(response, self.device_1.name)
+        self.assertNotContains(response, self.device_2.name)
+
+    def test_search_user_filter_narrows_within_static_constraint(self):
+        response = self.get(_search='true', _static_site_id=[self.site_1.pk, self.site_2.pk], site_id=self.site_2.pk)
+        self.assertContains(response, self.device_2.name)
+        self.assertNotContains(response, self.device_1.name)
+
+    def test_modal_does_not_prefill_filter_form_from_static_param(self):
+        response = self.get(_static_site_id=self.site_1.pk)
+        self.assertNotContains(response, 'type="hidden" name="site_id"')

+ 18 - 1
netbox/netbox/views/htmx.py

@@ -1,11 +1,15 @@
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
-from django.http import Http404
+from django.http import Http404, QueryDict
 from django.shortcuts import render
 from django.utils.module_loading import import_string
 from django.views.generic import View
 
+# Static query_params from the originating field are sent under this prefix so they can be told apart from filters
+# the user chooses in the modal. Both templates that emit these parameters must use the same prefix.
+STATIC_PARAM_PREFIX = '_static_'
+
 
 class ObjectSelectorView(LoginRequiredMixin, View):
     template_name = 'htmx/object_selector.html'
@@ -13,6 +17,14 @@ class ObjectSelectorView(LoginRequiredMixin, View):
     def get(self, request):
         model = self._get_model(request.GET.get('_model', ''))
 
+        # Constraints from the field's query_params. These are applied separately from the user's own filters, so
+        # choosing another value in the visible form can only narrow the results, never widen them.
+        static_params = {
+            key[len(STATIC_PARAM_PREFIX):]: request.GET.getlist(key)
+            for key in request.GET
+            if key.startswith(STATIC_PARAM_PREFIX)
+        }
+
         form_class = self._get_form_class(model)
         form = form_class(request.GET)
 
@@ -22,6 +34,10 @@ class ObjectSelectorView(LoginRequiredMixin, View):
 
             queryset = model.objects.restrict(request.user)
             if filterset:
+                static_data = QueryDict(mutable=True)
+                for key, values in static_params.items():
+                    static_data.setlist(key, values)
+                queryset = filterset(static_data, queryset, request=request).qs
                 queryset = filterset(request.GET, queryset, request=request).qs
 
             return render(request, 'htmx/object_selector_results.html', {
@@ -32,6 +48,7 @@ class ObjectSelectorView(LoginRequiredMixin, View):
             'form': form,
             'model': model,
             'target_id': request.GET.get('target'),
+            'static_params': static_params,
         })
 
     def _get_model(self, label):

+ 5 - 0
netbox/templates/htmx/object_selector.html

@@ -19,6 +19,11 @@
   <div class="col-9">
     <form data-object-selector hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, change, keyup from:#id_q delay:500ms">
       <input type="hidden" name="_search" value="true" />
+      {% for k, values in static_params.items %}
+        {% for v in values %}
+          <input type="hidden" name="_static_{{ k }}" value="{{ v }}" />
+        {% endfor %}
+      {% endfor %}
       <div class="tab-content p-1">
         {% for field in form.visible_fields %}
           <div class="collapse selector{{ forloop.counter }}{% if field.name in form.selector_fields %} show{% endif %}">{% render_field field %}</div>

+ 4 - 0
netbox/utilities/forms/widgets/apiselect.py

@@ -27,6 +27,10 @@ class APISelect(forms.Select):
         # Add quick-add context data, if enabled for the widget
         if hasattr(self, 'quick_add_context'):
             context['quick_add'] = self.quick_add_context
+        # Read from attrs: __deepcopy__ resets static_params but leaves the serialized attribute intact
+        raw = self.attrs.get('data-static-params', '[]')
+        parsed = json.loads(raw)
+        context['static_params'] = {item['queryParam']: item['queryValue'] for item in parsed}
 
         return context
 

+ 1 - 1
netbox/utilities/templates/widgets/apiselect.html

@@ -10,7 +10,7 @@
       class="btn btn-outline-secondary ms-1"
       data-bs-toggle="modal"
       data-bs-target="#htmx-modal"
-      hx-get="{% url 'htmx_object_selector' %}?_model={{ widget.attrs.selector }}&target={{ widget.attrs.id }}"
+      hx-get="{% url 'htmx_object_selector' %}?_model={{ widget.attrs.selector }}&target={{ widget.attrs.id }}{% for k, values in static_params.items %}{% for v in values %}&_static_{{ k|urlencode }}={{ v|urlencode }}{% endfor %}{% endfor %}"
       hx-target="#htmx-modal-content"
     >
       <i class="mdi mdi-database-search-outline" aria-hidden="true"></i>

+ 60 - 0
netbox/utilities/tests/test_apiselect.py

@@ -0,0 +1,60 @@
+import copy
+
+from django.test import TestCase
+
+from utilities.forms.widgets import APISelect
+
+
+class APISelectDeepcopyTest(TestCase):
+
+    def test_static_params_survive_deepcopy(self):
+        widget = APISelect()
+        widget.add_query_params({'site_id': 1})
+        copied_widget = copy.deepcopy(widget)
+
+        # __deepcopy__ resets static_params but leaves the serialized attribute in place
+        self.assertEqual(copied_widget.static_params, {})
+        self.assertIn('data-static-params', copied_widget.attrs)
+
+        context = copied_widget.get_context('site_id', None, {})
+        self.assertEqual(context['static_params'], {'site_id': [1]})
+
+    def test_static_params_empty_when_none_set(self):
+        widget = APISelect()
+        context = widget.get_context('site_id', None, {})
+        self.assertEqual(context['static_params'], {})
+
+
+class APISelectSelectorButtonTest(TestCase):
+
+    def make_widget(self, query_params=None):
+        widget = APISelect()
+        widget.attrs['selector'] = 'dcim.device'
+        if query_params:
+            widget.add_query_params(query_params)
+        return widget
+
+    def render(self, widget):
+        return widget.render('select_vm', None, attrs={'id': 'id_select_vm'})
+
+    def test_button_includes_static_params(self):
+        html = self.render(self.make_widget({'site_id': 1}))
+        self.assertIn('&_static_site_id=1', html)
+
+    def test_button_includes_static_params_after_deepcopy(self):
+        widget = copy.deepcopy(self.make_widget({'site_id': 1}))
+        self.assertIn('&_static_site_id=1', self.render(widget))
+
+    def test_button_includes_every_value_of_a_multi_value_param(self):
+        html = self.render(self.make_widget({'site_id': [1, 5]}))
+        self.assertIn('&_static_site_id=1', html)
+        self.assertIn('&_static_site_id=5', html)
+
+    def test_button_urlencodes_static_param_keys_and_values(self):
+        html = self.render(self.make_widget({'a&b': 'x&y=z'}))
+        self.assertIn('&_static_a%26b=x%26y%3Dz', html)
+        self.assertNotIn('=x&y=z', html)
+
+    def test_button_has_no_params_without_query_params(self):
+        html = self.render(self.make_widget())
+        self.assertNotIn('&_static_', html)