diff --git a/README.md b/README.md
index e3c9611c0..99ad9a597 100644
--- a/README.md
+++ b/README.md
@@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
[](https://netboxlabs.com)
[](https://try.digitalocean.com/developer-cloud)
-
- [](https://ns1.com)
[](https://sentry.io)
diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md
index c98e62b81..5f086bca4 100644
--- a/docs/release-notes/version-3.4.md
+++ b/docs/release-notes/version-3.4.md
@@ -4,11 +4,20 @@
### Enhancements
+* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
### Bug Fixes
+* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
+* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
+* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
+* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
+* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes
+* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
+* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
+* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
---
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 3168509ba..461113147 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
+ filterset = filtersets.CircuitTypeFilterSet
table = tables.CircuitTypeTable
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 493ccbbea..fb59659d3 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -24,6 +24,7 @@ __all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
+ 'CommonInterfaceFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet',
@@ -1321,11 +1322,45 @@ class PowerOutletFilterSet(
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
+class CommonInterfaceFilterSet(django_filters.FilterSet):
+ vlan_id = django_filters.CharFilter(
+ method='filter_vlan_id',
+ label=_('Assigned VLAN')
+ )
+ vlan = django_filters.CharFilter(
+ method='filter_vlan',
+ label=_('Assigned VID')
+ )
+ vrf_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vrf',
+ queryset=VRF.objects.all(),
+ label=_('VRF'),
+ )
+ vrf = django_filters.ModelMultipleChoiceFilter(
+ field_name='vrf__rd',
+ queryset=VRF.objects.all(),
+ to_field_name='rd',
+ label=_('VRF (RD)'),
+ )
+ l2vpn_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='l2vpn_terminations__l2vpn',
+ queryset=L2VPN.objects.all(),
+ label=_('L2VPN (ID)'),
+ )
+ l2vpn = django_filters.ModelMultipleChoiceFilter(
+ field_name='l2vpn_terminations__l2vpn__identifier',
+ queryset=L2VPN.objects.all(),
+ to_field_name='identifier',
+ label=_('L2VPN'),
+ )
+
+
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
- PathEndpointFilterSet
+ PathEndpointFilterSet,
+ CommonInterfaceFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
# members
@@ -1370,14 +1405,6 @@ class InterfaceFilterSet(
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
- vlan_id = django_filters.CharFilter(
- method='filter_vlan_id',
- label=_('Assigned VLAN')
- )
- vlan = django_filters.CharFilter(
- method='filter_vlan',
- label=_('Assigned VID')
- )
type = django_filters.MultipleChoiceFilter(
choices=InterfaceTypeChoices,
null_value=None
@@ -1388,17 +1415,6 @@ class InterfaceFilterSet(
rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices
)
- vrf_id = django_filters.ModelMultipleChoiceFilter(
- field_name='vrf',
- queryset=VRF.objects.all(),
- label=_('VRF'),
- )
- vrf = django_filters.ModelMultipleChoiceFilter(
- field_name='vrf__rd',
- queryset=VRF.objects.all(),
- to_field_name='rd',
- label=_('VRF (RD)'),
- )
vdc_id = django_filters.ModelMultipleChoiceFilter(
field_name='vdcs',
queryset=VirtualDeviceContext.objects.all(),
@@ -1416,17 +1432,6 @@ class InterfaceFilterSet(
to_field_name='name',
label='Virtual Device Context',
)
- l2vpn_id = django_filters.ModelMultipleChoiceFilter(
- field_name='l2vpn_terminations__l2vpn',
- queryset=L2VPN.objects.all(),
- label=_('L2VPN (ID)'),
- )
- l2vpn = django_filters.ModelMultipleChoiceFilter(
- field_name='l2vpn_terminations__l2vpn__identifier',
- queryset=L2VPN.objects.all(),
- to_field_name='identifier',
- label=_('L2VPN'),
- )
class Meta:
model = Interface
diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py
index 11fdfa6d2..4127aa3ea 100644
--- a/netbox/dcim/forms/bulk_create.py
+++ b/netbox/dcim/forms/bulk_create.py
@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
model = ModuleBay
- field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
+ field_order = ('name', 'label', 'position', 'description', 'tags')
replication_fields = ('name', 'label', 'position')
- position_pattern = ExpandableNameField(
+ position = ExpandableNameField(
label=_('Position'),
required=False,
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index 062734355..cf5f30ee4 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -152,8 +152,6 @@ class Cable(PrimaryModel):
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
- elif self.length is None:
- self.length_unit = ''
if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.")
@@ -187,6 +185,10 @@ class Cable(PrimaryModel):
else:
self._abs_length = None
+ # Clear length_unit if no length is defined
+ if self.length is None:
+ self.length_unit = ''
+
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 26a6ade98..b879b77d3 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -794,8 +794,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
})
- elif self.rf_channel:
- self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
# Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width:
@@ -803,8 +801,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
- elif self.rf_channel:
- self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
# VLAN validation
@@ -815,6 +811,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
f"interface's parent device, or it must be global."
})
+ def save(self, *args, **kwargs):
+
+ # Set absolute channel attributes from selected options
+ if self.rf_channel and not self.rf_channel_frequency:
+ self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
+ if self.rf_channel and not self.rf_channel_width:
+ self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
+
+ super().save(*args, **kwargs)
+
@property
def _occupied(self):
return super()._occupied or bool(self.wireless_link_id)
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 603129228..0526c49cb 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -659,8 +659,6 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
})
- elif self.rack:
- self.location = self.rack.location
if self.rack is None:
if self.face:
@@ -776,8 +774,10 @@ class Device(PrimaryModel, ConfigContextModel):
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
- components = [obj.instantiate(device=self) for obj in queryset]
- if components and bulk_create:
+ if bulk_create:
+ components = [obj.instantiate(device=self) for obj in queryset]
+ if not components:
+ return
model = components[0]._meta.model
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
@@ -790,8 +790,9 @@ class Device(PrimaryModel, ConfigContextModel):
using='default',
update_fields=None
)
- elif components:
- for component in components:
+ else:
+ for obj in queryset:
+ component = obj.instantiate(device=self)
component.save()
def save(self, *args, **kwargs):
@@ -801,6 +802,9 @@ class Device(PrimaryModel, ConfigContextModel):
if is_new and not self.airflow:
self.airflow = self.device_type.airflow
+ if self.rack and self.rack.location:
+ self.location = self.rack.location
+
super().save(*args, **kwargs)
# If this is a new Device, instantiate all the related components per the DeviceType definition
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 03be2fdb3..e61e0f2a3 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width/depth")
- elif self.outer_width is None and self.outer_depth is None:
- self.outer_unit = ''
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
else:
self._abs_max_weight = None
+ # Clear unit if outer width & depth are not set
+ if self.outer_width is None and self.outer_depth is None:
+ self.outer_unit = ''
+
super().save(*args, **kwargs)
@property
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 9b49e799c..5a6261eba 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
+ filterset = filtersets.RackRoleFilterSet
table = tables.RackRoleTable
@@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer')
)
+ filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable
@@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
+ filterset = filtersets.DeviceRoleFilterSet
table = tables.DeviceRoleTable
@@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView):
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
+ filterset = filtersets.PlatformFilterSet
table = tables.PlatformTable
@@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItem.objects.all()
+ filterset = filtersets.InventoryItemFilterSet
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
+ filterset = filtersets.InventoryItemRoleFilterSet
table = tables.InventoryItemRoleTable
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index fa16b8501..836562b95 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -215,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
"""
for ct in content_types:
model = ct.model_class()
- instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
+ instances = model.objects.filter(custom_field_data__has_key=self.name)
for instance in instances:
del instance.custom_field_data[self.name]
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 81a607eec..4b5efda3c 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -1,13 +1,10 @@
import datetime
-from unittest import skipIf
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware
-from django_rq.queues import get_connection
from rest_framework import status
-from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
@@ -16,8 +13,6 @@ from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
-rq_worker_running = Worker.count(get_connection('default'))
-
class AppTest(APITestCase):
@@ -539,16 +534,6 @@ class ReportTest(APITestCase):
self.assertEqual(response.data['name'], self.TestReport.__name__)
- @skipIf(not rq_worker_running, "RQ worker not running")
- def test_run_report(self):
- self.add_permissions('extras.run_script')
-
- url = reverse('extras-api:report-run', kwargs={'pk': None})
- response = self.client.post(url, {}, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_200_OK)
-
- self.assertEqual(response.data['result']['status']['value'], 'pending')
-
class ScriptTest(APITestCase):
@@ -589,27 +574,6 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
- @skipIf(not rq_worker_running, "RQ worker not running")
- def test_run_script(self):
- self.add_permissions('extras.run_script')
-
- script_data = {
- 'var1': 'FooBar',
- 'var2': 123,
- 'var3': False,
- }
-
- data = {
- 'data': script_data,
- 'commit': True,
- }
-
- url = reverse('extras-api:script-detail', kwargs={'pk': None})
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_200_OK)
-
- self.assertEqual(response.data['result']['status']['value'], 'pending')
-
class CreatedUpdatedFilterTest(APITestCase):
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 91d3b5c58..d8a3c377d 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
+ filterset = filtersets.ConfigContextFilterSet
table = tables.ConfigContextTable
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index e8bf13375..9effc3add 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
if self.prefix:
- # Clear host bits from prefix
- self.prefix = self.prefix.cidr
-
# /0 masks are not acceptable
if self.prefix.prefixlen == 0:
raise ValidationError({
diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py
index ea6441650..5af1bc769 100644
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.add_permissions('ipam.delete_vlan')
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
- with disable_warnings('django.request'):
+ with disable_warnings('netbox.api.views.ModelViewSet'):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 130014f3f..5f10fb5a7 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView):
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
+ filterset = filtersets.RoleFilterSet
table = tables.RoleTable
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 6060475d8..211b869e6 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -16,7 +16,6 @@ from django_tables2.export import TableExport
from extras.models import ExportTemplate
from extras.signals import clear_webhooks
-from utilities.choices import ImportFormatChoices
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
@@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
]
nullified_fields = request.POST.getlist('_nullify')
updated_objects = []
+ model_fields = {}
+ m2m_fields = {}
+
+ # Build list of model fields and m2m fields for later iteration
+ for name in standard_fields:
+ try:
+ model_field = self.queryset.model._meta.get_field(name)
+ if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
+ m2m_fields[name] = model_field
+ else:
+ model_fields[name] = model_field
+
+ except FieldDoesNotExist:
+ # This form field is used to modify a field rather than set its value directly
+ model_fields[name] = None
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
@@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.snapshot()
# Update standard fields. If a field is listed in _nullify, delete its value.
- for name in standard_fields:
-
- try:
- model_field = self.queryset.model._meta.get_field(name)
- except FieldDoesNotExist:
- # This form field is used to modify a field rather than set its value directly
- model_field = None
-
+ for name, model_field in model_fields.items():
# Handle nullification
if name in form.nullable_fields and name in nullified_fields:
- if isinstance(model_field, ManyToManyField):
- getattr(obj, name).set([])
- else:
- setattr(obj, name, None if model_field.null else '')
-
- # ManyToManyFields
- elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
- if form.cleaned_data[name]:
- getattr(obj, name).set(form.cleaned_data[name])
+ setattr(obj, name, None if model_field.null else '')
# Normal fields
elif name in form.changed_data:
setattr(obj, name, form.cleaned_data[name])
@@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.save()
updated_objects.append(obj)
+ # Handle M2M fields after save
+ for name, m2m_field in m2m_fields.items():
+ if name in form.nullable_fields and name in nullified_fields:
+ getattr(obj, name).clear()
+ else:
+ getattr(obj, name).set(form.cleaned_data[name])
+
# Add/remove tags
if form.cleaned_data.get('add_tags', None):
obj.tags.add(*form.cleaned_data['add_tags'])
diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html
index 1c747b44b..0ad0637e3 100644
--- a/netbox/templates/dcim/cable_edit.html
+++ b/netbox/templates/dcim/cable_edit.html
@@ -98,7 +98,7 @@