Compare commits

..

1 Commits

Author SHA1 Message Date
Arthur
ef45059502 #21150 fix filterset UI performance 2026-01-15 13:27:34 -08:00
15 changed files with 113 additions and 122 deletions

View File

@@ -30,13 +30,13 @@ jobs:
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -140,6 +140,9 @@ class FrontPortFormMixin(forms.Form):
widget=forms.SelectMultiple(attrs={'size': 8})
)
port_mapping_model = PortMapping
parent_field = 'device'
def clean(self):
super().clean()
@@ -200,22 +203,3 @@ class FrontPortFormMixin(forms.Form):
using=connection,
update_fields=None
)
def _get_rear_port_choices(self, parent_filter, front_port):
"""
Return a list of choices representing each available rear port & position pair on the parent object (identified
by a Q filter), excluding those assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
]
choices = []
for rear_port in self.rear_port_model.objects.filter(parent_filter):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append((pair_id, pair_label))
return choices

View File

@@ -1124,8 +1124,9 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
),
)
# Override FrontPortFormMixin attrs
port_mapping_model = PortTemplateMapping
rear_port_model = RearPortTemplate
parent_field = 'device_type'
class Meta:
model = FrontPortTemplate
@@ -1136,14 +1137,13 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rear port choices based on parent DeviceType or ModuleType
if device_type_id := self.data.get('device_type') or self.initial.get('device_type'):
parent_filter = Q(device_type=device_type_id)
elif module_type_id := self.data.get('module_type') or self.initial.get('module_type'):
parent_filter = Q(module_type=module_type_id)
device_type = DeviceType.objects.get(pk=device_type_id)
else:
return
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
# Populate rear port choices
self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance)
# Set initial rear port mappings
if self.instance.pk:
@@ -1152,6 +1152,27 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk)
]
def _get_rear_port_choices(self, device_type, front_port):
"""
Return a list of choices representing each available rear port & position pair on the device type, excluding
those assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in device_type.port_mappings.exclude(front_port=front_port.pk)
]
choices = []
for rear_port in RearPortTemplate.objects.filter(device_type=device_type):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append(
(pair_id, pair_label)
)
return choices
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
@@ -1598,9 +1619,6 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
),
)
port_mapping_model = PortMapping
rear_port_model = RearPort
class Meta:
model = FrontPort
fields = [
@@ -1611,12 +1629,13 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rear port choices
if device_id := self.data.get('device') or self.initial.get('device'):
parent_filter = Q(device=device_id)
device = Device.objects.get(pk=device_id)
else:
return
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
# Populate rear port choices
self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance)
# Set initial rear port mappings
if self.instance.pk:
@@ -1625,6 +1644,27 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk)
]
def _get_rear_port_choices(self, device, front_port):
"""
Return a list of choices representing each available rear port & position pair on the device, excluding those
assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in device.port_mappings.exclude(front_port=front_port.pk)
]
choices = []
for rear_port in RearPort.objects.filter(device=device):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append(
(pair_id, pair_label)
)
return choices
class RearPortForm(ModularDeviceComponentForm):
fieldsets = (

View File

@@ -211,16 +211,12 @@ def sync_cached_scope_fields(instance, created, **kwargs):
for model in (Prefix, Cluster, WirelessLAN):
qs = model.objects.filter(**filters)
# Bulk update cached fields to avoid O(N) performance issues with large datasets.
# This does not trigger post_save signals, avoiding spurious change log entries.
objects_to_update = []
for obj in qs:
# Recompute cache using the same logic as save()
obj.cache_related_objects()
objects_to_update.append(obj)
if objects_to_update:
model.objects.bulk_update(
objects_to_update,
['_location', '_site', '_site_group', '_region']
)
obj.save(update_fields=[
'_location',
'_site',
'_site_group',
'_region',
])

View File

@@ -31,7 +31,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{} mm')
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
class RackNumberingPanel(panels.ObjectAttributesPanel):

View File

@@ -1845,7 +1845,6 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
class ModuleTypeBulkRenameView(generic.BulkRenameView):
queryset = ModuleType.objects.all()
filterset = filtersets.ModuleTypeFilterSet
field_name = 'model'
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)

View File

@@ -538,7 +538,7 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
selector_fields = ('filter_id', 'q', 'group_id')
selector_fields = ('filter_id', 'q', 'site_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@@ -372,8 +372,8 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
})
# Disable object assignment fields if the IP address is designated as primary or OOB
if self.initial.get('primary_for_parent') or self.initial.get('oob_for_parent'):
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True
self.fields['vminterface'].disabled = True
self.fields['fhrpgroup'].disabled = True

View File

@@ -940,13 +940,6 @@ class IPAddress(ContactsMixin, PrimaryModel):
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
)
# can't use is_oob_ip as self.assigned_object might be changed
if hasattr(original_parent, 'oob_ip') and original_parent.oob_ip_id == self.pk:
if parent != original_parent:
raise ValidationError(
_("Cannot reassign IP address while it is designated as the OOB IP for the parent object")
)
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
import strawberry_django
from strawberry import ID
from strawberry_django import ComparisonFilterLookup, FilterLookup
from strawberry_django import FilterLookup
from core.graphql.filter_mixins import ChangeLoggingMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
@@ -23,7 +23,7 @@ __all__ = (
@dataclass
class BaseModelFilter:
id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field()
id: FilterLookup[ID] | None = strawberry_django.filter_field()
class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):

View File

@@ -164,7 +164,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
"""
label = name[:1].upper() + name[1:]
label = label.replace('_', ' ')
return _(label)
return label
def get_context(self, context):
# Determine which attributes to display in the panel based on only/exclude args

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-16 05:04+0000\n"
"POT-Creation-Date: 2026-01-15 05:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -2435,7 +2435,7 @@ msgstr ""
msgid "Change logging is not supported for this object type ({type})."
msgstr ""
#: netbox/core/models/config.py:21 netbox/core/models/data.py:284
#: netbox/core/models/config.py:21 netbox/core/models/data.py:282
#: netbox/core/models/files.py:29 netbox/core/models/jobs.py:60
#: netbox/extras/models/models.py:847 netbox/extras/models/notifications.py:39
#: netbox/extras/models/notifications.py:195
@@ -2541,63 +2541,58 @@ msgstr ""
msgid "Unknown backend type: {type}"
msgstr ""
#: netbox/core/models/data.py:131
#, python-brace-format
msgid "URLs for local sources must start with {scheme} (or specify no scheme)"
msgstr ""
#: netbox/core/models/data.py:182
#: netbox/core/models/data.py:180
msgid "Cannot initiate sync; syncing already in progress."
msgstr ""
#: netbox/core/models/data.py:195
#: netbox/core/models/data.py:193
msgid ""
"There was an error initializing the backend. A dependency needs to be "
"installed: "
msgstr ""
#: netbox/core/models/data.py:288 netbox/core/models/files.py:33
#: netbox/core/models/data.py:286 netbox/core/models/files.py:33
#: netbox/netbox/models/features.py:67
msgid "last updated"
msgstr ""
#: netbox/core/models/data.py:298 netbox/dcim/models/cables.py:622
#: netbox/core/models/data.py:296 netbox/dcim/models/cables.py:622
msgid "path"
msgstr ""
#: netbox/core/models/data.py:301
#: netbox/core/models/data.py:299
msgid "File path relative to the data source's root"
msgstr ""
#: netbox/core/models/data.py:305 netbox/ipam/models/ip.py:507
#: netbox/core/models/data.py:303 netbox/ipam/models/ip.py:507
msgid "size"
msgstr ""
#: netbox/core/models/data.py:308
#: netbox/core/models/data.py:306
msgid "hash"
msgstr ""
#: netbox/core/models/data.py:312
#: netbox/core/models/data.py:310
msgid "Length must be 64 hexadecimal characters."
msgstr ""
#: netbox/core/models/data.py:314
#: netbox/core/models/data.py:312
msgid "SHA256 hash of the file data"
msgstr ""
#: netbox/core/models/data.py:328
#: netbox/core/models/data.py:326
msgid "data file"
msgstr ""
#: netbox/core/models/data.py:329
#: netbox/core/models/data.py:327
msgid "data files"
msgstr ""
#: netbox/core/models/data.py:402
#: netbox/core/models/data.py:400
msgid "auto sync record"
msgstr ""
#: netbox/core/models/data.py:403
#: netbox/core/models/data.py:401
msgid "auto sync records"
msgstr ""
@@ -11245,13 +11240,7 @@ msgid ""
"parent object"
msgstr ""
#: netbox/ipam/models/ip.py:947
msgid ""
"Cannot reassign IP address while it is designated as the OOB IP for the "
"parent object"
msgstr ""
#: netbox/ipam/models/ip.py:953
#: netbox/ipam/models/ip.py:946
msgid "Only IPv6 addresses can be assigned SLAAC status"
msgstr ""

View File

@@ -188,13 +188,11 @@ class FilterModifierMixin:
key = f'{model._meta.app_label}.{model._meta.model_name}'
filterset_class = registry['filtersets'].get(key)
filterset = filterset_class() if filterset_class else None
for field_name, field in self.fields.items():
lookups = self._get_lookup_choices(field)
if filterset:
lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset)
if filterset_class:
lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset_class)
if len(lookups) > 1:
field.widget = FilterModifierWidget(
@@ -213,8 +211,14 @@ class FilterModifierMixin:
return []
def _verify_lookups_with_filterset(self, field_name, lookups, filterset):
"""Verify which lookups are actually supported by the FilterSet."""
def _verify_lookups_with_filterset(self, field_name, lookups, filterset_class):
"""Verify which lookups are actually supported by the FilterSet.
Args:
field_name: The name of the form field
lookups: List of (lookup_code, lookup_label) tuples to verify
filterset_class: The FilterSet class (not instance) to check against
"""
verified_lookups = []
for lookup_code, lookup_label in lookups:
@@ -223,7 +227,7 @@ class FilterModifierMixin:
else:
filter_key = f'{field_name}__{lookup_code}' if lookup_code != 'exact' else field_name
if filter_key in filterset.filters:
if filter_key in filterset_class.base_filters:
verified_lookups.append((lookup_code, lookup_label))
return verified_lookups

View File

@@ -1,8 +1,6 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
__all__ = (
'FilterModifierWidget',
'MODIFIER_EMPTY_FALSE',
@@ -96,37 +94,9 @@ class FilterModifierWidget(forms.Widget):
# to the original widget before rendering
self.original_widget.attrs.update(self.attrs)
# For APISelect/APISelectMultiple widgets, temporarily clear choices to prevent queryset evaluation
original_choices = None
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
original_choices = self.original_widget.choices
# Only keep selected choices to preserve 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
]
else:
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 = []
# Get context from the original widget
original_context = self.original_widget.get_context(name, value, attrs)
# Restore original choices if we modified them
if original_choices is not None:
self.original_widget.choices = original_choices
# Build our wrapper context
context = super().get_context(name, value, attrs)
context['widget']['original_widget'] = original_context['widget']

16
scripts/git-hooks/pre-commit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# TODO: Remove this file in NetBox v4.3
# This script has been maintained to ease transition to the pre-commit tool.
exec 1>&2
EXIT=0
RED='\033[0;31m'
YELLOW='\033[0;33m'
NOCOLOR='\033[0m'
printf "${YELLOW}The pre-commit hook script is obsolete. Please use pre-commit instead:${NOCOLOR}\n"
printf " pip install pre-commit\n"
printf " pre-commit install${NOCOLOR}\n"
exit 1