Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Novinger
cedbeb7b19 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
2026-01-23 09:36:15 -06:00
8 changed files with 119 additions and 37 deletions

View File

@@ -86,7 +86,7 @@ def enqueue_event(queue, instance, request, event_type):
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None): def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
user = None # To be resolved from the username if needed user = User.objects.get(username=username) if username else None
for event_rule in event_rules: for event_rule in event_rules:
@@ -134,10 +134,6 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
# Resolve the script from action parameters # Resolve the script from action parameters
script = event_rule.action_object.python_class() script = event_rule.action_object.python_class()
# Retrieve the User if not already resolved
if user is None:
user = User.objects.get(username=username)
# Enqueue a Job to record the script's execution # Enqueue a Job to record the script's execution
from extras.jobs import ScriptJob from extras.jobs import ScriptJob
params = { params = {

View File

@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top"> <a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i></a> <i class="mdi mdi-image"></i></a>
{% endif %} {% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a> <a href="{{ record.get_absolute_url }}">{{ record }}</a>
""" """
NOTIFICATION_ICON = """ NOTIFICATION_ICON = """

View File

@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
verbose_name=_('IP Address') 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): class Meta(IPAddressTable.Meta):
pass pass

View File

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

View File

@@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
if prefix_list and show_available: if prefix_list and show_available:
# Find all unallocated space, add fake Prefix objects to child_prefixes. # 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 = 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()] available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
child_prefixes = child_prefixes + available_prefixes child_prefixes = child_prefixes + available_prefixes

View File

@@ -409,10 +409,60 @@ ADMIN_MENU = Menu(
MenuGroup( MenuGroup(
label=_('Authentication'), label=_('Authentication'),
items=( items=(
get_model_item('users', 'user', _('Users')), MenuItem(
get_model_item('users', 'group', _('Groups')), link='users:user_list',
get_model_item('users', 'token', _('API Tokens')), link_text=_('Users'),
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), staff_only=True,
permissions=['users.view_user'],
buttons=(
MenuItemButton(
link='users:user_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['users.add_user']
),
MenuItemButton(
link='users:user_bulk_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['users.add_user']
)
)
),
MenuItem(
link='users:group_list',
link_text=_('Groups'),
staff_only=True,
permissions=['users.view_group'],
buttons=(
MenuItemButton(
link='users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['users.add_group']
),
MenuItemButton(
link='users:group_bulk_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['users.add_group']
)
)
),
MenuItem(
link='users:token_list',
link_text=_('API Tokens'),
staff_only=True,
permissions=['users.view_token'],
buttons=get_model_buttons('users', 'token')
),
MenuItem(
link='users:objectpermission_list',
link_text=_('Permissions'),
staff_only=True,
permissions=['users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
),
), ),
), ),
MenuGroup( MenuGroup(

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-22 05:07+0000\n" "POT-Creation-Date: 2026-01-21 05:07+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -12037,7 +12037,7 @@ msgstr ""
msgid "date synced" msgid "date synced"
msgstr "" msgstr ""
#: netbox/netbox/models/features.py:621 #: netbox/netbox/models/features.py:623
#, python-brace-format #, python-brace-format
msgid "{class_name} must implement a sync_data() method." msgid "{class_name} must implement a sync_data() method."
msgstr "" msgstr ""
@@ -13935,8 +13935,8 @@ msgid "No VLANs Assigned"
msgstr "" msgstr ""
#: netbox/templates/dcim/inc/interface_vlans_table.html:44 #: netbox/templates/dcim/inc/interface_vlans_table.html:44
#: netbox/templates/ipam/inc/max_depth.html:11 #: netbox/templates/ipam/prefix_list.html:16
#: netbox/templates/ipam/inc/max_length.html:11 #: netbox/templates/ipam/prefix_list.html:33
msgid "Clear" msgid "Clear"
msgstr "" msgstr ""
@@ -15053,8 +15053,8 @@ msgstr ""
msgid "Date Added" msgid "Date Added"
msgstr "" msgstr ""
#: netbox/templates/ipam/aggregate/prefixes.html:10 #: netbox/templates/ipam/aggregate/prefixes.html:8
#: netbox/templates/ipam/prefix/prefixes.html:10 #: netbox/templates/ipam/prefix/prefixes.html:8
#: netbox/templates/ipam/role.html:10 #: netbox/templates/ipam/role.html:10
msgid "Add Prefix" msgid "Add Prefix"
msgstr "" msgstr ""
@@ -15083,14 +15083,6 @@ msgstr ""
msgid "Bulk Create" msgid "Bulk Create"
msgstr "" msgstr ""
#: netbox/templates/ipam/inc/max_depth.html:6
msgid "Max Depth"
msgstr ""
#: netbox/templates/ipam/inc/max_length.html:6
msgid "Max Length"
msgstr ""
#: netbox/templates/ipam/inc/panels/fhrp_groups.html:10 #: netbox/templates/ipam/inc/panels/fhrp_groups.html:10
msgid "Create Group" msgid "Create Group"
msgstr "" msgstr ""
@@ -15192,6 +15184,14 @@ msgstr ""
msgid "Hide Depth Indicators" msgid "Hide Depth Indicators"
msgstr "" msgstr ""
#: netbox/templates/ipam/prefix_list.html:11
msgid "Max Depth"
msgstr ""
#: netbox/templates/ipam/prefix_list.html:28
msgid "Max Length"
msgstr ""
#: netbox/templates/ipam/rir.html:10 #: netbox/templates/ipam/rir.html:10
msgid "Add Aggregate" msgid "Add Aggregate"
msgstr "" msgstr ""

View File

@@ -252,16 +252,3 @@ def isodatetime(value, spec='seconds'):
else: else:
return '' return ''
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>') return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
@register.filter
def truncate_middle(value, length):
if len(value) <= length:
return value
# Calculate split points for the two parts
half_len = (length - 1) // 2 # 1 for the ellipsis
first_part = value[:half_len]
second_part = value[len(value) - (length - 1 - half_len):]
return mark_safe(f"{first_part}&hellip;{second_part}")