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/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index 1e4a35332..1fccd0270 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
```postgresql
CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
-GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
+ALTER DATABASE netbox OWNER TO netbox;
```
!!! danger "Use a strong password"
diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md
index 47f590d59..5f086bca4 100644
--- a/docs/release-notes/version-3.4.md
+++ b/docs/release-notes/version-3.4.md
@@ -2,6 +2,23 @@
## v3.4.8 (FUTURE)
+### 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
+
---
## v3.4.7 (2023-03-28)
diff --git a/mkdocs.yml b/mkdocs.yml
index bf4251e0e..ae7fcadf8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -8,6 +8,9 @@ theme:
custom_dir: docs/_theme/
icon:
repo: fontawesome/brands/github
+ features:
+ - content.code.copy
+ - navigation.footer
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
@@ -20,7 +23,8 @@ theme:
icon: material/lightbulb
name: Switch to Light Mode
plugins:
- - search
+ - search:
+ lang: en
- mkdocstrings:
handlers:
python:
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index fc9540d16..e5f4faee1 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -247,6 +247,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 6f7ac419e..4f3122456 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -25,6 +25,7 @@ __all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
+ 'CommonInterfaceFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet',
@@ -1348,11 +1349,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
@@ -1397,14 +1432,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
@@ -1415,17 +1442,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(),
@@ -1443,17 +1459,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 a6b8be57a..b497a90d2 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -797,8 +797,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:
@@ -806,8 +804,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
@@ -818,6 +814,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 de6d1bc83..e0eceea1f 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -707,8 +707,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:
@@ -824,8 +822,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
@@ -838,8 +838,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):
@@ -853,6 +854,10 @@ class Device(PrimaryModel, ConfigContextModel):
if is_new and not self.platform:
self.platform = self.device_type.default_platform
+ # Inherit location from Rack if not set
+ 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 e6a9b02d4..e5412a3ab 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 114df410a..a6920f46e 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -578,6 +578,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
+ filterset = filtersets.RackRoleFilterSet
table = tables.RackRoleTable
@@ -867,6 +868,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
+ filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable
@@ -1728,6 +1730,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
+ filterset = filtersets.DeviceRoleFilterSet
table = tables.DeviceRoleTable
@@ -1785,6 +1788,7 @@ class PlatformBulkEditView(generic.BulkEditView):
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
+ filterset = filtersets.PlatformFilterSet
table = tables.PlatformTable
@@ -2853,6 +2857,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'
@@ -2909,6 +2914,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 73d5cf9ce..18430300f 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -221,7 +221,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
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/models/models.py b/netbox/extras/models/models.py
index 61dae5c99..ef604df91 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -306,7 +306,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
max_length=50,
blank=True,
verbose_name='MIME type',
- help_text=_('Defaults to text/plain
')
+ help_text=_('Defaults to text/plain; charset=utf-8
')
)
file_extension = models.CharField(
max_length=15,
@@ -368,7 +368,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
Render the template to an HTTP response, delivered as a named file attachment
"""
output = self.render(queryset)
- mime_type = 'text/plain' if not self.mime_type else self.mime_type
+ mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
# Build the response
response = HttpResponse(output, content_type=mime_type)
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index aa293e318..ef6fa605b 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 core.choices import ManagedFileRootPathChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
@@ -17,8 +14,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):
@@ -547,16 +542,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):
@@ -603,27 +588,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 4e25539ce..685ee853b 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -422,6 +422,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 9dff944bb..28901ab8e 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -120,9 +120,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 3908c8f3d..b6aeccc1a 100644
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -952,7 +952,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 1d1ef1d06..a49c4aab3 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -465,6 +465,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 549e1cd8c..8ca77ce55 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -503,6 +503,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']):
@@ -511,25 +526,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])
@@ -547,6 +547,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/device_edit.html b/netbox/templates/dcim/device_edit.html
index d0677ad20..17780b513 100644
--- a/netbox/templates/dcim/device_edit.html
+++ b/netbox/templates/dcim/device_edit.html
@@ -108,7 +108,6 @@