Просмотр исходного кода

Merge branch 'develop' into feature

jeremystretch 2 лет назад
Родитель
Сommit
08017c51f6
39 измененных файлов с 166 добавлено и 205 удалено
  1. 0 2
      README.md
  2. 1 1
      docs/installation/1-postgresql.md
  3. 17 0
      docs/release-notes/version-3.4.md
  4. 5 1
      mkdocs.yml
  5. 1 0
      netbox/circuits/views.py
  6. 36 31
      netbox/dcim/filtersets.py
  7. 2 2
      netbox/dcim/forms/bulk_create.py
  8. 4 2
      netbox/dcim/models/cables.py
  9. 10 4
      netbox/dcim/models/device_components.py
  10. 11 6
      netbox/dcim/models/devices.py
  11. 4 2
      netbox/dcim/models/racks.py
  12. 6 0
      netbox/dcim/views.py
  13. 1 1
      netbox/extras/models/customfields.py
  14. 2 2
      netbox/extras/models/models.py
  15. 0 36
      netbox/extras/tests/test_api.py
  16. 1 0
      netbox/extras/views.py
  17. 0 3
      netbox/ipam/models/ip.py
  18. 1 1
      netbox/ipam/tests/test_api.py
  19. 1 0
      netbox/ipam/views.py
  20. 24 17
      netbox/netbox/views/generic/bulk_views.py
  21. 0 1
      netbox/templates/dcim/device_edit.html
  22. 0 1
      netbox/templates/dcim/rack_edit.html
  23. 0 1
      netbox/templates/dcim/virtualchassis_edit.html
  24. 0 1
      netbox/templates/htmx/form.html
  25. 0 3
      netbox/templates/ipam/ipaddress_edit.html
  26. 0 3
      netbox/templates/ipam/service_create.html
  27. 0 3
      netbox/templates/ipam/service_edit.html
  28. 0 3
      netbox/templates/ipam/vlan_edit.html
  29. 0 39
      netbox/templates/wireless/wirelesslink_edit.html
  30. 3 0
      netbox/tenancy/views.py
  31. 13 4
      netbox/users/tests/test_api.py
  32. 2 2
      netbox/utilities/forms/fields/fields.py
  33. 5 3
      netbox/utilities/templates/buttons/clone.html
  34. 1 3
      netbox/utilities/templates/form_helpers/render_field.html
  35. 2 0
      netbox/utilities/templatetags/buttons.py
  36. 2 24
      netbox/virtualization/filtersets.py
  37. 8 2
      netbox/virtualization/models/virtualmachines.py
  38. 1 1
      netbox/virtualization/tests/test_models.py
  39. 2 0
      netbox/virtualization/views.py

+ 0 - 2
README.md

@@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
   [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
             
   [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
-            
-  [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
   <br />
   [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

+ 1 - 1
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"

+ 17 - 0
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)

+ 5 - 1
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:

+ 1 - 0
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
 
 

+ 36 - 31
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

+ 2 - 2
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.)')

+ 4 - 2
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)

+ 10 - 4
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)

+ 11 - 6
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

+ 4 - 2
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

+ 6 - 0
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
 
 

+ 1 - 1
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)

+ 2 - 2
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 <code>text/plain</code>')
+        help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
     )
     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)

+ 0 - 36
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):
 

+ 1 - 0
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
 
 

+ 0 - 3
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({

+ 1 - 1
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)

+ 1 - 0
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
 
 

+ 24 - 17
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'])

+ 0 - 1
netbox/templates/dcim/device_edit.html

@@ -108,7 +108,6 @@
     </div>
 
     <div class="field-group mb-5">
-      <h5 class="text-center">Comments</h5>
       {% render_field form.comments %}
     </div>
 

+ 0 - 1
netbox/templates/dcim/rack_edit.html

@@ -83,7 +83,6 @@
     {% endif %}
 
     <div class="field-group my-5">
-      <h5 class="text-center">Comments</h5>
       {% render_field form.comments %}
     </div>
 {% endblock %}

+ 0 - 1
netbox/templates/dcim/virtualchassis_edit.html

@@ -27,7 +27,6 @@
         </div>
 
         <div class="field-group my-5">
-          <h5 class="text-center">Comments</h5>
           {% render_field vc_form.comments %}
         </div>
 

+ 0 - 1
netbox/templates/htmx/form.html

@@ -36,7 +36,6 @@
 
   {% if form.comments %}
     <div class="field-group mb-5">
-      <h5 class="text-center">Comments</h5>
       {% render_field form.comments %}
     </div>
   {% endif %}

+ 0 - 3
netbox/templates/ipam/ipaddress_edit.html

@@ -134,9 +134,6 @@
     </div>
 
     <div class="field-group my-5">
-      <div class="row mb-2">
-        <h5 class="text-center">Comments</h5>
-      </div>
       {% render_field form.comments %}
     </div>
 

+ 0 - 3
netbox/templates/ipam/service_create.html

@@ -66,9 +66,6 @@
   </div>
 
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
   </div>
 

+ 0 - 3
netbox/templates/ipam/service_edit.html

@@ -53,9 +53,6 @@
   </div>
 
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
   </div>
 

+ 0 - 3
netbox/templates/ipam/vlan_edit.html

@@ -53,9 +53,6 @@
   </div>
 
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
   </div>
 

+ 0 - 39
netbox/templates/wireless/wirelesslink_edit.html

@@ -1,39 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-  <div class="row">
-    <div class="col">
-      <div class="field-group">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Side A</h5>
-        </div>
-        {% render_field form.device_a %}
-        {% render_field form.interface_a %}
-      </div>
-    </div>
-    <div class="col">
-      <div class="field-group">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Side B</h5>
-        </div>
-        {% render_field form.device_b %}
-        {% render_field form.interface_b %}
-      </div>
-    </div>
-  </div>
-  <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="offset-sm-3">Comments</h5>
-    </div>
-    {% render_field form.comments %}
-  </div>
-  {% if form.custom_fields %}
-    <div class="field-group my-5">
-      <div class="row mb-2">
-        <h5 class="offset-sm-3">Custom Fields</h5>
-      </div>
-      {% render_custom_fields form %}
-    </div>
-  {% endif %}
-{% endblock %}

+ 3 - 0
netbox/tenancy/views.py

@@ -83,6 +83,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
         'tenant_count',
         cumulative=True
     )
+    filterset = filtersets.TenantGroupFilterSet
     table = tables.TenantGroupTable
 
 
@@ -233,6 +234,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
         'contact_count',
         cumulative=True
     )
+    filterset = filtersets.ContactGroupFilterSet
     table = tables.ContactGroupTable
 
 
@@ -286,6 +288,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
 
 class ContactRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = ContactRole.objects.all()
+    filterset = filtersets.ContactRoleFilterSet
     table = tables.ContactRoleTable
 
 

+ 13 - 4
netbox/users/tests/test_api.py

@@ -12,7 +12,7 @@ class AppTest(APITestCase):
     def test_root(self):
 
         url = reverse('users-api:api-root')
-        response = self.client.get('{}?format=api'.format(url), **self.header)
+        response = self.client.get(f'{url}?format=api', **self.header)
 
         self.assertEqual(response.status_code, 200)
 
@@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
             'password': 'password6',
         },
     ]
+    bulk_update_data = {
+        'email': 'test@example.com',
+    }
 
     @classmethod
     def setUpTestData(cls):
 
         users = (
-            User(username='User_1'),
-            User(username='User_2'),
-            User(username='User_3'),
+            User(username='User_1', password='password1'),
+            User(username='User_2', password='password2'),
+            User(username='User_3', password='password3'),
         )
         User.objects.bulk_create(users)
 
@@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
         )
         Group.objects.bulk_create(users)
 
+    def test_bulk_update_objects(self):
+        """
+        Disabled test. There's no attribute we can set in bulk for Groups.
+        """
+        return
+
 
 class TokenTest(
     # No GraphQL support for Token

+ 2 - 2
netbox/utilities/forms/fields/fields.py

@@ -34,8 +34,8 @@ class CommentField(forms.CharField):
         Markdown</a> syntax is supported
     """
 
-    def __init__(self, *, label='', help_text=help_text, required=False, **kwargs):
-        super().__init__(label=label, help_text=help_text, required=required, **kwargs)
+    def __init__(self, *, help_text=help_text, required=False, **kwargs):
+        super().__init__(help_text=help_text, required=required, **kwargs)
 
 
 class SlugField(forms.SlugField):

+ 5 - 3
netbox/utilities/templates/buttons/clone.html

@@ -1,3 +1,5 @@
-<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
-    <i class="mdi mdi-content-copy" aria-hidden="true"></i>&nbsp;Clone
-</a>
+{% if url %}
+  <a href="{{ url }}" class="btn btn-sm btn-success" role="button">
+    <i class="mdi mdi-content-copy" aria-hidden="true"></i> Clone
+  </a>
+{% endif %}

+ 1 - 3
netbox/utilities/templates/form_helpers/render_field.html

@@ -3,9 +3,7 @@
 
 <div class="row mb-3{% if field.errors %} has-errors{% endif %}">
 
-  {# Render the field label, except for: #}
-  {#   1. Checkboxes (label appears to the right of the field #}
-  {#   2. Textareas with no label set (will expand across entire row) #}
+  {# Render the field label (if any), except for checkboxes #}
   {% if label and not field|widget_type == 'checkboxinput' %}
     <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
       {{ label }}

+ 2 - 0
netbox/utilities/templatetags/buttons.py

@@ -20,6 +20,8 @@ def clone_button(instance):
     param_string = prepare_cloned_fields(instance).urlencode()
     if param_string:
         url = f'{url}?{param_string}'
+    else:
+        url = None
 
     return {
         'url': url,

+ 2 - 24
netbox/virtualization/filtersets.py

@@ -2,9 +2,9 @@ import django_filters
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
+from dcim.filtersets import CommonInterfaceFilterSet
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
-from ipam.models import L2VPN, VRF
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -250,7 +250,7 @@ class VirtualMachineFilterSet(
         return queryset.exclude(params)
 
 
-class VMInterfaceFilterSet(NetBoxModelFilterSet):
+class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
     cluster_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_machine__cluster',
         queryset=Cluster.objects.all(),
@@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
     mac_address = MultiValueMACAddressFilter(
         label=_('MAC address'),
     )
-    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 Meta:
         model = VMInterface

+ 8 - 2
netbox/virtualization/models/virtualmachines.py

@@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
             raise ValidationError({
                 'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
             })
-        elif self.cluster:
-            self.site = self.cluster.site
 
         # Validate assigned cluster device
         if self.device and not self.cluster:
@@ -201,6 +199,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
                         field: f"The specified IP address ({ip}) is not assigned to this VM.",
                     })
 
+    def save(self, *args, **kwargs):
+
+        # Assign site from cluster if not set
+        if self.cluster and not self.site:
+            self.site = self.cluster.site
+
+        super().save(*args, **kwargs)
+
     def get_status_color(self):
         return VirtualMachineStatusChoices.colors.get(self.status)
 

+ 1 - 1
netbox/virtualization/tests/test_models.py

@@ -72,7 +72,7 @@ class VirtualMachineTestCase(TestCase):
 
         # VM with cluster site but no direct site should have its site set automatically
         vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
-        vm.full_clean()
+        vm.save()
         self.assertEqual(vm.site, sites[0])
 
     def test_vm_name_case_sensitivity(self):

+ 2 - 0
netbox/virtualization/views.py

@@ -74,6 +74,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
     )
+    filterset = filtersets.ClusterTypeFilterSet
     table = tables.ClusterTypeTable
 
 
@@ -135,6 +136,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
     )
+    filterset = filtersets.ClusterGroupFilterSet
     table = tables.ClusterGroupTable