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

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)
   [![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)
   [![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 />
   <br />
   [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
   [![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;
   &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
 ```postgresql
 CREATE DATABASE netbox;
 CREATE DATABASE netbox;
 CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
 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"
 !!! danger "Use a strong password"

+ 17 - 0
docs/release-notes/version-3.4.md

@@ -2,6 +2,23 @@
 
 
 ## v3.4.8 (FUTURE)
 ## 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)
 ## v3.4.7 (2023-03-28)

+ 5 - 1
mkdocs.yml

@@ -8,6 +8,9 @@ theme:
   custom_dir: docs/_theme/
   custom_dir: docs/_theme/
   icon:
   icon:
     repo: fontawesome/brands/github
     repo: fontawesome/brands/github
+  features:
+    - content.code.copy
+    - navigation.footer
   palette:
   palette:
     - media: "(prefers-color-scheme: light)"
     - media: "(prefers-color-scheme: light)"
       scheme: default
       scheme: default
@@ -20,7 +23,8 @@ theme:
         icon: material/lightbulb
         icon: material/lightbulb
         name: Switch to Light Mode
         name: Switch to Light Mode
 plugins:
 plugins:
-  - search
+  - search:
+      lang: en
   - mkdocstrings:
   - mkdocstrings:
       handlers:
       handlers:
         python:
         python:

+ 1 - 0
netbox/circuits/views.py

@@ -247,6 +247,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
         circuit_count=count_related(Circuit, 'type')
     )
     )
+    filterset = filtersets.CircuitTypeFilterSet
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
 
 
 
 

+ 36 - 31
netbox/dcim/filtersets.py

@@ -25,6 +25,7 @@ __all__ = (
     'CableFilterSet',
     'CableFilterSet',
     'CabledObjectFilterSet',
     'CabledObjectFilterSet',
     'CableTerminationFilterSet',
     'CableTerminationFilterSet',
+    'CommonInterfaceFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsolePortFilterSet',
     'ConsolePortFilterSet',
     'ConsolePortTemplateFilterSet',
     'ConsolePortTemplateFilterSet',
@@ -1348,11 +1349,45 @@ class PowerOutletFilterSet(
         fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
         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(
 class InterfaceFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
     CabledObjectFilterSet,
     CabledObjectFilterSet,
-    PathEndpointFilterSet
+    PathEndpointFilterSet,
+    CommonInterfaceFilterSet
 ):
 ):
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
     # members
     # members
@@ -1397,14 +1432,6 @@ class InterfaceFilterSet(
     poe_type = django_filters.MultipleChoiceFilter(
     poe_type = django_filters.MultipleChoiceFilter(
         choices=InterfacePoETypeChoices
         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(
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
         choices=InterfaceTypeChoices,
         null_value=None
         null_value=None
@@ -1415,17 +1442,6 @@ class InterfaceFilterSet(
     rf_channel = django_filters.MultipleChoiceFilter(
     rf_channel = django_filters.MultipleChoiceFilter(
         choices=WirelessChannelChoices
         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(
     vdc_id = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs',
         field_name='vdcs',
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
@@ -1443,17 +1459,6 @@ class InterfaceFilterSet(
         to_field_name='name',
         to_field_name='name',
         label='Virtual Device Context',
         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:
     class Meta:
         model = Interface
         model = Interface

+ 2 - 2
netbox/dcim/forms/bulk_create.py

@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
 
 
 class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
 class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
     model = ModuleBay
     model = ModuleBay
-    field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
+    field_order = ('name', 'label', 'position', 'description', 'tags')
     replication_fields = ('name', 'label', 'position')
     replication_fields = ('name', 'label', 'position')
-    position_pattern = ExpandableNameField(
+    position = ExpandableNameField(
         label=_('Position'),
         label=_('Position'),
         required=False,
         required=False,
         help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
         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
         # Validate length and length_unit
         if self.length is not None and not self.length_unit:
         if self.length is not None and not self.length_unit:
             raise ValidationError("Must specify a unit when setting a cable length")
             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):
         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.")
             raise ValidationError("Must define A and B terminations when creating a new cable.")
@@ -187,6 +185,10 @@ class Cable(PrimaryModel):
         else:
         else:
             self._abs_length = None
             self._abs_length = None
 
 
+        # Clear length_unit if no length is defined
+        if self.length is None:
+            self.length_unit = ''
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
         # 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({
                 raise ValidationError({
                     'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
                     '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)
         # Validate channel width against interface type and selected channel (if any)
         if self.rf_channel_width:
         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."})
                 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'):
             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."})
                 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
         # VLAN validation
 
 
@@ -818,6 +814,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
                                  f"interface's parent device, or it must be global."
                                  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
     @property
     def _occupied(self):
     def _occupied(self):
         return super()._occupied or bool(self.wireless_link_id)
         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({
             raise ValidationError({
                 'rack': f"Rack {self.rack} does not belong to location {self.location}.",
                 '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.rack is None:
             if self.face:
             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
             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.
                          (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 = components[0]._meta.model
             model.objects.bulk_create(components)
             model.objects.bulk_create(components)
             # Manually send the post_save signal for each of the newly created components
             # Manually send the post_save signal for each of the newly created components
@@ -838,8 +838,9 @@ class Device(PrimaryModel, ConfigContextModel):
                     using='default',
                     using='default',
                     update_fields=None
                     update_fields=None
                 )
                 )
-        elif components:
-            for component in components:
+        else:
+            for obj in queryset:
+                component = obj.instantiate(device=self)
                 component.save()
                 component.save()
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -853,6 +854,10 @@ class Device(PrimaryModel, ConfigContextModel):
         if is_new and not self.platform:
         if is_new and not self.platform:
             self.platform = self.device_type.default_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)
         super().save(*args, **kwargs)
 
 
         # If this is a new Device, instantiate all the related components per the DeviceType definition
         # 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
         # Validate outer dimensions and unit
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_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")
             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
         # Validate max_weight and weight_unit
         if self.max_weight and not self.weight_unit:
         if self.max_weight and not self.weight_unit:
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
         else:
         else:
             self._abs_max_weight = None
             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)
         super().save(*args, **kwargs)
 
 
     @property
     @property

+ 6 - 0
netbox/dcim/views.py

@@ -578,6 +578,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
         rack_count=count_related(Rack, 'role')
     )
     )
+    filterset = filtersets.RackRoleFilterSet
     table = tables.RackRoleTable
     table = tables.RackRoleTable
 
 
 
 
@@ -867,6 +868,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         platform_count=count_related(Platform, 'manufacturer')
         platform_count=count_related(Platform, 'manufacturer')
     )
     )
+    filterset = filtersets.ManufacturerFilterSet
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
 
 
 
 
@@ -1728,6 +1730,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
         device_count=count_related(Device, 'device_role'),
         device_count=count_related(Device, 'device_role'),
         vm_count=count_related(VirtualMachine, 'role')
         vm_count=count_related(VirtualMachine, 'role')
     )
     )
+    filterset = filtersets.DeviceRoleFilterSet
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
 
 
 
 
@@ -1785,6 +1788,7 @@ class PlatformBulkEditView(generic.BulkEditView):
 
 
 class PlatformBulkDeleteView(generic.BulkDeleteView):
 class PlatformBulkDeleteView(generic.BulkDeleteView):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
+    filterset = filtersets.PlatformFilterSet
     table = tables.PlatformTable
     table = tables.PlatformTable
 
 
 
 
@@ -2853,6 +2857,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
 
 
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
+    filterset = filtersets.InventoryItemFilterSet
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     template_name = 'dcim/inventoryitem_bulk_delete.html'
 
 
@@ -2909,6 +2914,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = InventoryItemRole.objects.annotate(
     queryset = InventoryItemRole.objects.annotate(
         inventoryitem_count=count_related(InventoryItem, 'role'),
         inventoryitem_count=count_related(InventoryItem, 'role'),
     )
     )
+    filterset = filtersets.InventoryItemRoleFilterSet
     table = tables.InventoryItemRoleTable
     table = tables.InventoryItemRoleTable
 
 
 
 

+ 1 - 1
netbox/extras/models/customfields.py

@@ -221,7 +221,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         """
         """
         for ct in content_types:
         for ct in content_types:
             model = ct.model_class()
             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:
             for instance in instances:
                 del instance.custom_field_data[self.name]
                 del instance.custom_field_data[self.name]
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
             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,
         max_length=50,
         blank=True,
         blank=True,
         verbose_name='MIME type',
         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(
     file_extension = models.CharField(
         max_length=15,
         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
         Render the template to an HTTP response, delivered as a named file attachment
         """
         """
         output = self.render(queryset)
         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
         # Build the response
         response = HttpResponse(output, content_type=mime_type)
         response = HttpResponse(output, content_type=mime_type)

+ 0 - 36
netbox/extras/tests/test_api.py

@@ -1,13 +1,10 @@
 import datetime
 import datetime
-from unittest import skipIf
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware
 from django.utils.timezone import make_aware
-from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
-from rq import Worker
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 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 extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
-rq_worker_running = Worker.count(get_connection('default'))
-
 
 
 class AppTest(APITestCase):
 class AppTest(APITestCase):
 
 
@@ -547,16 +542,6 @@ class ReportTest(APITestCase):
 
 
         self.assertEqual(response.data['name'], self.TestReport.__name__)
         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):
 class ScriptTest(APITestCase):
 
 
@@ -603,27 +588,6 @@ class ScriptTest(APITestCase):
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
         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):
 class CreatedUpdatedFilterTest(APITestCase):
 
 

+ 1 - 0
netbox/extras/views.py

@@ -422,6 +422,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
 
 
 class ConfigContextBulkDeleteView(generic.BulkDeleteView):
 class ConfigContextBulkDeleteView(generic.BulkDeleteView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
+    filterset = filtersets.ConfigContextFilterSet
     table = tables.ConfigContextTable
     table = tables.ConfigContextTable
 
 
 
 

+ 0 - 3
netbox/ipam/models/ip.py

@@ -120,9 +120,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
 
 
         if self.prefix:
         if self.prefix:
 
 
-            # Clear host bits from prefix
-            self.prefix = self.prefix.cidr
-
             # /0 masks are not acceptable
             # /0 masks are not acceptable
             if self.prefix.prefixlen == 0:
             if self.prefix.prefixlen == 0:
                 raise ValidationError({
                 raise ValidationError({

+ 1 - 1
netbox/ipam/tests/test_api.py

@@ -952,7 +952,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
 
 
         self.add_permissions('ipam.delete_vlan')
         self.add_permissions('ipam.delete_vlan')
         url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
         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)
             response = self.client.delete(url, **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
         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):
 class RoleBulkDeleteView(generic.BulkDeleteView):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
+    filterset = filtersets.RoleFilterSet
     table = tables.RoleTable
     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')
         nullified_fields = request.POST.getlist('_nullify')
         updated_objects = []
         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']):
         for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
 
 
@@ -511,25 +526,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                 obj.snapshot()
                 obj.snapshot()
 
 
             # Update standard fields. If a field is listed in _nullify, delete its value.
             # 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
                 # Handle nullification
                 if name in form.nullable_fields and name in nullified_fields:
                 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
                 # Normal fields
                 elif name in form.changed_data:
                 elif name in form.changed_data:
                     setattr(obj, name, form.cleaned_data[name])
                     setattr(obj, name, form.cleaned_data[name])
@@ -547,6 +547,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
             obj.save()
             obj.save()
             updated_objects.append(obj)
             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
             # Add/remove tags
             if form.cleaned_data.get('add_tags', None):
             if form.cleaned_data.get('add_tags', None):
                 obj.tags.add(*form.cleaned_data['add_tags'])
                 obj.tags.add(*form.cleaned_data['add_tags'])

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -53,9 +53,6 @@
   </div>
   </div>
 
 
   <div class="field-group my-5">
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
     {% render_field form.comments %}
   </div>
   </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',
         'tenant_count',
         cumulative=True
         cumulative=True
     )
     )
+    filterset = filtersets.TenantGroupFilterSet
     table = tables.TenantGroupTable
     table = tables.TenantGroupTable
 
 
 
 
@@ -233,6 +234,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
     )
     )
+    filterset = filtersets.ContactGroupFilterSet
     table = tables.ContactGroupTable
     table = tables.ContactGroupTable
 
 
 
 
@@ -286,6 +288,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
 
 
 class ContactRoleBulkDeleteView(generic.BulkDeleteView):
 class ContactRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = ContactRole.objects.all()
     queryset = ContactRole.objects.all()
+    filterset = filtersets.ContactRoleFilterSet
     table = tables.ContactRoleTable
     table = tables.ContactRoleTable
 
 
 
 

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

@@ -12,7 +12,7 @@ class AppTest(APITestCase):
     def test_root(self):
     def test_root(self):
 
 
         url = reverse('users-api:api-root')
         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)
         self.assertEqual(response.status_code, 200)
 
 
@@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
             'password': 'password6',
             'password': 'password6',
         },
         },
     ]
     ]
+    bulk_update_data = {
+        'email': 'test@example.com',
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
         users = (
         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)
         User.objects.bulk_create(users)
 
 
@@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
         )
         )
         Group.objects.bulk_create(users)
         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(
 class TokenTest(
     # No GraphQL support for Token
     # 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
         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):
 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 %}">
 <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' %}
   {% 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 for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
       {{ label }}
       {{ label }}

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

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

+ 2 - 24
netbox/virtualization/filtersets.py

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

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

@@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
             raise ValidationError({
             raise ValidationError({
                 'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
                 '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
         # Validate assigned cluster device
         if self.device and not self.cluster:
         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.",
                         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):
     def get_status_color(self):
         return VirtualMachineStatusChoices.colors.get(self.status)
         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 with cluster site but no direct site should have its site set automatically
         vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
         vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
-        vm.full_clean()
+        vm.save()
         self.assertEqual(vm.site, sites[0])
         self.assertEqual(vm.site, sites[0])
 
 
     def test_vm_name_case_sensitivity(self):
     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(
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
         cluster_count=count_related(Cluster, 'type')
     )
     )
+    filterset = filtersets.ClusterTypeFilterSet
     table = tables.ClusterTypeTable
     table = tables.ClusterTypeTable
 
 
 
 
@@ -135,6 +136,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
         cluster_count=count_related(Cluster, 'group')
     )
     )
+    filterset = filtersets.ClusterGroupFilterSet
     table = tables.ClusterGroupTable
     table = tables.ClusterGroupTable