From cedbeb7b19666df9d8d09c34e629d8d3a8bc8961 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Fri, 23 Jan 2026 09:36:15 -0600 Subject: [PATCH] Fixes #21176: Remove checkboxes from IP ranges in mixed-type tables When IP addresses and IP ranges are displayed together in a prefix's IP Addresses tab, only IP addresses should be selectable for bulk operations since the bulk delete form doesn't support mixed object types. - Override render_pk() in AnnotatedIPAddressTable to conditionally render checkboxes only for the table's primary model type (IPAddress) - Add warning comment to add_requested_prefixes() about fake Prefix objects - Add regression test to verify IPAddress has checkboxes but IPRange does not --- netbox/ipam/tables/ip.py | 5 ++++ netbox/ipam/tests/test_tables.py | 41 ++++++++++++++++++++++++++++++++ netbox/ipam/utils.py | 3 +++ 3 files changed, 49 insertions(+) create mode 100644 netbox/ipam/tests/test_tables.py diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 707f7f5be..a173db714 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable): verbose_name=_('IP Address') ) + def render_pk(self, value, record, bound_column): + if type(record) is not self._meta.model: + return '' + return bound_column.column.render(value, bound_column, record) + class Meta(IPAddressTable.Meta): pass diff --git a/netbox/ipam/tests/test_tables.py b/netbox/ipam/tests/test_tables.py new file mode 100644 index 000000000..2a6220f33 --- /dev/null +++ b/netbox/ipam/tests/test_tables.py @@ -0,0 +1,41 @@ +from django.test import RequestFactory, TestCase +from netaddr import IPNetwork + +from ipam.models import IPAddress, IPRange, Prefix +from ipam.tables import AnnotatedIPAddressTable +from ipam.utils import annotate_ip_space + + +class AnnotatedIPAddressTableTest(TestCase): + + @classmethod + def setUpTestData(cls): + cls.prefix = Prefix.objects.create( + prefix=IPNetwork('10.1.1.0/24'), + status='active' + ) + + cls.ip_address = IPAddress.objects.create( + address='10.1.1.1/24', + status='active' + ) + + cls.ip_range = IPRange.objects.create( + start_address=IPNetwork('10.1.1.2/24'), + end_address=IPNetwork('10.1.1.10/24'), + status='active' + ) + + def test_ipaddress_has_checkbox_iprange_does_not(self): + data = annotate_ip_space(self.prefix) + table = AnnotatedIPAddressTable(data, orderable=False) + table.columns.show('pk') + + request = RequestFactory().get('/') + html = table.as_html(request) + + ipaddress_checkbox_count = html.count(f'name="pk" value="{self.ip_address.pk}"') + self.assertEqual(ipaddress_checkbox_count, 1) + + iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"') + self.assertEqual(iprange_checkbox_count, 0) diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 790ac6503..53885367e 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign if prefix_list and show_available: # Find all unallocated space, add fake Prefix objects to child_prefixes. + # IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use + # saved Prefix instances with real pks, bulk delete will fail for mixed-type selections + # due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176 available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list]) available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()] child_prefixes = child_prefixes + available_prefixes