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

Merge branch 'feature' into 5284-vlangroup-scope

Jeremy Stretch 4 лет назад
Родитель
Сommit
10778f8479
38 измененных файлов с 760 добавлено и 170 удалено
  1. 1 0
      docs/release-notes/version-2.11.md
  2. 3 5
      netbox/circuits/api/serializers.py
  3. 14 0
      netbox/circuits/forms.py
  4. 4 0
      netbox/circuits/tests/test_views.py
  5. 1 0
      netbox/circuits/urls.py
  6. 9 0
      netbox/circuits/views.py
  7. 25 27
      netbox/dcim/api/serializers.py
  8. 141 0
      netbox/dcim/forms.py
  9. 1 62
      netbox/dcim/models/racks.py
  10. 65 0
      netbox/dcim/models/sites.py
  11. 50 0
      netbox/dcim/tests/test_filters.py
  12. 61 0
      netbox/dcim/tests/test_views.py
  13. 7 0
      netbox/dcim/urls.py
  14. 71 0
      netbox/dcim/views.py
  15. 2 9
      netbox/extras/api/nested_serializers.py
  16. 0 34
      netbox/extras/api/serializers.py
  17. 8 9
      netbox/ipam/api/serializers.py
  18. 53 0
      netbox/ipam/forms.py
  19. 12 0
      netbox/ipam/tests/test_views.py
  20. 3 0
      netbox/ipam/urls.py
  21. 25 0
      netbox/ipam/views.py
  22. 70 9
      netbox/netbox/api/serializers.py
  23. 4 5
      netbox/secrets/api/serializers.py
  24. 14 0
      netbox/secrets/forms.py
  25. 4 0
      netbox/secrets/tests/test_views.py
  26. 1 0
      netbox/secrets/urls.py
  27. 9 0
      netbox/secrets/views.py
  28. 2 3
      netbox/tenancy/api/serializers.py
  29. 18 0
      netbox/tenancy/forms.py
  30. 4 0
      netbox/tenancy/tests/test_views.py
  31. 1 0
      netbox/tenancy/urls.py
  32. 13 0
      netbox/tenancy/views.py
  33. 1 0
      netbox/utilities/testing/views.py
  34. 4 6
      netbox/virtualization/api/serializers.py
  35. 28 0
      netbox/virtualization/forms.py
  36. 8 0
      netbox/virtualization/tests/test_views.py
  37. 2 0
      netbox/virtualization/urls.py
  38. 21 1
      netbox/virtualization/views.py

+ 1 - 0
docs/release-notes/version-2.11.md

@@ -77,6 +77,7 @@ The ObjectChange model (which is used to record the creation, modification, and
 * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI
 * [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location
 * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models
+* [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models
 
 ### Other Changes
 

+ 3 - 5
netbox/circuits/api/serializers.py

@@ -4,10 +4,8 @@ from circuits.choices import CircuitStatusChoices
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
 from netbox.api import ChoiceField
-from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer
+from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 
@@ -16,7 +14,7 @@ from .nested_serializers import *
 # Providers
 #
 
-class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class ProviderSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
 
@@ -55,7 +53,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd
         ]
 
 
-class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class CircuitSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)

+ 14 - 0
netbox/circuits/forms.py

@@ -142,6 +142,20 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
         ]
 
 
+class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CircuitType.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 class CircuitTypeCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
 

+ 4 - 0
netbox/circuits/tests/test_views.py

@@ -73,6 +73,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Circuit Type 6,circuit-type-6",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'Foo',
+        }
+
 
 class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Circuit

+ 1 - 0
netbox/circuits/urls.py

@@ -23,6 +23,7 @@ urlpatterns = [
     path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
     path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
     path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
+    path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
     path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),

+ 9 - 0
netbox/circuits/views.py

@@ -107,6 +107,15 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
     table = tables.CircuitTypeTable
 
 
+class CircuitTypeBulkEditView(generic.BulkEditView):
+    queryset = CircuitType.objects.annotate(
+        circuit_count=count_related(Circuit, 'type')
+    )
+    filterset = filters.CircuitTypeFilterSet
+    table = tables.CircuitTypeTable
+    form = forms.CircuitTypeBulkEditForm
+
+
 class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')

+ 25 - 27
netbox/dcim/api/serializers.py

@@ -7,13 +7,12 @@ from rest_framework.validators import UniqueTogetherValidator
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
 from netbox.api.serializers import (
-    NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
+    NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
+    WritableNestedSerializer,
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -43,7 +42,7 @@ class CableTerminationSerializer(serializers.ModelSerializer):
         return None
 
 
-class ConnectedEndpointSerializer(CustomFieldModelSerializer):
+class ConnectedEndpointSerializer(serializers.ModelSerializer):
     connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
     connected_endpoint = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
@@ -101,7 +100,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
         ]
 
 
-class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class SiteSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = NestedRegionSerializer(required=False, allow_null=True)
@@ -155,7 +154,7 @@ class RackRoleSerializer(OrganizationalModelSerializer):
         ]
 
 
-class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RackSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
@@ -206,7 +205,7 @@ class RackUnitSerializer(serializers.Serializer):
     occupied = serializers.BooleanField(read_only=True)
 
 
-class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RackReservationSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
     rack = NestedRackSerializer()
     user = NestedUserSerializer()
@@ -271,7 +270,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
         ]
 
 
-class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class DeviceTypeSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer()
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
@@ -434,7 +433,7 @@ class PlatformSerializer(OrganizationalModelSerializer):
         ]
 
 
-class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class DeviceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
@@ -506,7 +505,11 @@ class DeviceNAPALMSerializer(serializers.Serializer):
     method = serializers.DictField()
 
 
-class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+#
+# Device components
+#
+
+class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -530,7 +533,7 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial
         ]
 
 
-class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -554,7 +557,7 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer,
         ]
 
 
-class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -583,7 +586,7 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer,
         ]
 
 
-class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -602,7 +605,7 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
         ]
 
 
-class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
@@ -643,7 +646,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
         return super().validate(data)
 
 
-class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
+class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
@@ -668,7 +671,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'label']
 
 
-class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
+class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
@@ -684,7 +687,7 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cu
         ]
 
 
-class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class DeviceBaySerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer()
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
@@ -701,7 +704,7 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Inventory items
 #
 
-class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class InventoryItemSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     # Provide a default value to satisfy UniqueTogetherValidator
@@ -721,7 +724,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer
 # Cables
 #
 
-class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class CableSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     termination_a_type = ContentTypeField(
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@@ -851,7 +854,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
 # Virtual chassis
 #
 
-class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VirtualChassisSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer(required=False)
     member_count = serializers.IntegerField(read_only=True)
@@ -865,7 +868,7 @@ class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerialize
 # Power panels
 #
 
-class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class PowerPanelSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(
@@ -880,12 +883,7 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
         fields = ['id', 'url', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
 
 
-class PowerFeedSerializer(
-    TaggedObjectSerializer,
-    CableTerminationSerializer,
-    ConnectedEndpointSerializer,
-    CustomFieldModelSerializer
-):
+class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(

+ 141 - 0
netbox/dcim/forms.py

@@ -201,6 +201,24 @@ class RegionCSVForm(CustomFieldModelCSVForm):
         fields = Region.csv_headers
 
 
+class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
 class RegionFilterForm(BootstrapMixin, forms.Form):
     model = Site
     q = forms.CharField(
@@ -240,6 +258,24 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm):
         fields = SiteGroup.csv_headers
 
 
+class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    parent = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
 class SiteGroupFilterForm(BootstrapMixin, forms.Form):
     model = Site
     q = forms.CharField(
@@ -480,6 +516,31 @@ class LocationCSVForm(CustomFieldModelCSVForm):
         fields = Location.csv_headers
 
 
+class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
 class LocationFilterForm(BootstrapMixin, forms.Form):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -530,6 +591,25 @@ class RackRoleCSVForm(CustomFieldModelCSVForm):
         }
 
 
+class RackRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RackRole.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = forms.CharField(
+        max_length=6,  # RGB color code
+        required=False,
+        widget=ColorSelect()
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['color', 'description']
+
+
 #
 # Racks
 #
@@ -1026,6 +1106,20 @@ class ManufacturerCSVForm(CustomFieldModelCSVForm):
         fields = Manufacturer.csv_headers
 
 
+class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 #
 # Device types
 #
@@ -1822,6 +1916,30 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm):
         }
 
 
+class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = forms.CharField(
+        max_length=6,  # RGB color code
+        required=False,
+        widget=ColorSelect()
+    )
+    vm_role = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label='VM role'
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['color', 'description']
+
+
 #
 # Platforms
 #
@@ -1859,6 +1977,29 @@ class PlatformCSVForm(CustomFieldModelCSVForm):
         fields = Platform.csv_headers
 
 
+class PlatformBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    napalm_driver = forms.CharField(
+        max_length=50,
+        required=False
+    )
+    # TODO: Bulk edit support for napalm_args
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['manufacturer', 'napalm_driver', 'description']
+
+
 #
 # Devices
 #

+ 1 - 62
netbox/dcim/models/racks.py

@@ -10,13 +10,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Count, Sum
 from django.urls import reverse
-from mptt.models import TreeForeignKey
 
 from dcim.choices import *
 from dcim.constants import *
 from dcim.elevations import RackElevationSVG
 from extras.utils import extras_features
-from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
+from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
@@ -27,7 +26,6 @@ from .power import PowerFeed
 
 __all__ = (
     'Rack',
-    'Location',
     'RackReservation',
     'RackRole',
 )
@@ -37,65 +35,6 @@ __all__ = (
 # Racks
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class Location(NestedGroupModel):
-    """
-    A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
-    site, or a room within a building, for example.
-    """
-    name = models.CharField(
-        max_length=100
-    )
-    slug = models.SlugField(
-        max_length=100
-    )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.CASCADE,
-        related_name='locations'
-    )
-    parent = TreeForeignKey(
-        to='self',
-        on_delete=models.CASCADE,
-        related_name='children',
-        blank=True,
-        null=True,
-        db_index=True
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-
-    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
-
-    class Meta:
-        ordering = ['site', 'name']
-        unique_together = [
-            ['site', 'name'],
-            ['site', 'slug'],
-        ]
-
-    def get_absolute_url(self):
-        return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk)
-
-    def to_csv(self):
-        return (
-            self.site,
-            self.parent.name if self.parent else '',
-            self.name,
-            self.slug,
-            self.description,
-        )
-
-    def clean(self):
-        super().clean()
-
-        # Parent Location (if any) must belong to the same Site
-        if self.parent and self.parent.site != self.site:
-            raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")
-
-
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 class RackRole(OrganizationalModel):
     """

+ 65 - 0
netbox/dcim/models/sites.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 from mptt.models import TreeForeignKey
@@ -13,6 +14,7 @@ from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 
 __all__ = (
+    'Location',
     'Region',
     'Site',
     'SiteGroup',
@@ -276,3 +278,66 @@ class Site(PrimaryModel):
 
     def get_status_class(self):
         return SiteStatusChoices.CSS_CLASSES.get(self.status)
+
+
+#
+# Locations
+#
+
+@extras_features('custom_fields', 'export_templates', 'webhooks')
+class Location(NestedGroupModel):
+    """
+    A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
+    site, or a room within a building, for example.
+    """
+    name = models.CharField(
+        max_length=100
+    )
+    slug = models.SlugField(
+        max_length=100
+    )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='locations'
+    )
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = [
+            ['site', 'name'],
+            ['site', 'slug'],
+        ]
+
+    def get_absolute_url(self):
+        return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk)
+
+    def to_csv(self):
+        return (
+            self.site,
+            self.parent.name if self.parent else '',
+            self.name,
+            self.slug,
+            self.description,
+        )
+
+    def clean(self):
+        super().clean()
+
+        # Parent Location (if any) must belong to the same Site
+        if self.parent and self.parent.site != self.site:
+            raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")

+ 50 - 0
netbox/dcim/tests/test_filters.py

@@ -59,6 +59,56 @@ class RegionTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+class SiteGroupTestCase(TestCase):
+    queryset = SiteGroup.objects.all()
+    filterset = SiteGroupFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        sitegroups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1', description='A'),
+            SiteGroup(name='Site Group 2', slug='site-group-2', description='B'),
+            SiteGroup(name='Site Group 3', slug='site-group-3', description='C'),
+        )
+        for sitegroup in sitegroups:
+            sitegroup.save()
+
+        child_sitegroups = (
+            SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]),
+            SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]),
+            SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]),
+            SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]),
+            SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]),
+            SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]),
+        )
+        for sitegroup in child_sitegroups:
+            sitegroup.save()
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Site Group 1', 'Site Group 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_slug(self):
+        params = {'slug': ['site-group-1', 'site-group-2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_parent(self):
+        parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+
 class SiteTestCase(TestCase):
     queryset = Site.objects.all()
     filterset = SiteFilterSet

+ 61 - 0
netbox/dcim/tests/test_views.py

@@ -57,6 +57,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Region 6,region-6,Sixth region",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
+class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = SiteGroup
+
+    @classmethod
+    def setUpTestData(cls):
+
+        # Create three SiteGroups
+        sitegroups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for sitegroup in sitegroups:
+            sitegroup.save()
+
+        cls.form_data = {
+            'name': 'Site Group X',
+            'slug': 'site-group-x',
+            'parent': sitegroups[2].pk,
+            'description': 'A new site group',
+        }
+
+        cls.csv_data = (
+            "name,slug,description",
+            "Site Group 4,site-group-4,Fourth site group",
+            "Site Group 5,site-group-5,Fifth site group",
+            "Site Group 6,site-group-6,Sixth site group",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Site
@@ -157,6 +195,10 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Site 1,Location 6,location-6,Sixth location",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RackRole
@@ -184,6 +226,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Rack Role 6,rack-role-6,0000ff",
         )
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
 
 class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = RackReservation
@@ -345,6 +392,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Manufacturer 6,manufacturer-6,Sixth manufacturer",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of bulk import view for DeviceTypes
@@ -894,6 +945,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Device Role 6,device-role-6,0000ff",
         )
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
 
 class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Platform
@@ -925,6 +981,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Platform 6,platform-6,Sixth platform",
         )
 
+        cls.bulk_edit_data = {
+            'napalm_driver': 'ios',
+            'description': 'New description',
+        }
+
 
 class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Device

+ 7 - 0
netbox/dcim/urls.py

@@ -12,6 +12,7 @@ urlpatterns = [
     path('regions/', views.RegionListView.as_view(), name='region_list'),
     path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
     path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
+    path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'),
     path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
     path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
@@ -21,6 +22,7 @@ urlpatterns = [
     path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'),
     path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'),
     path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'),
+    path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'),
     path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'),
     path('site-groups/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
     path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
@@ -42,6 +44,7 @@ urlpatterns = [
     path('locations/', views.LocationListView.as_view(), name='location_list'),
     path('locations/add/', views.LocationEditView.as_view(), name='location_add'),
     path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'),
+    path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'),
     path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'),
     path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
     path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
@@ -51,6 +54,7 @@ urlpatterns = [
     path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
     path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
     path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
+    path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'),
     path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
@@ -84,6 +88,7 @@ urlpatterns = [
     path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
     path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
+    path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'),
     path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
@@ -168,6 +173,7 @@ urlpatterns = [
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
     path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
+    path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'),
     path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
@@ -177,6 +183,7 @@ urlpatterns = [
     path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
     path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
     path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
+    path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'),
     path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
     path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),

+ 71 - 0
netbox/dcim/views.py

@@ -126,6 +126,19 @@ class RegionBulkImportView(generic.BulkImportView):
     table = tables.RegionTable
 
 
+class RegionBulkEditView(generic.BulkEditView):
+    queryset = Region.objects.add_related_count(
+        Region.objects.all(),
+        Site,
+        'region',
+        'site_count',
+        cumulative=True
+    )
+    filterset = filters.RegionFilterSet
+    table = tables.RegionTable
+    form = forms.RegionBulkEditForm
+
+
 class RegionBulkDeleteView(generic.BulkDeleteView):
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
@@ -170,6 +183,19 @@ class SiteGroupBulkImportView(generic.BulkImportView):
     table = tables.SiteGroupTable
 
 
+class SiteGroupBulkEditView(generic.BulkEditView):
+    queryset = SiteGroup.objects.add_related_count(
+        SiteGroup.objects.all(),
+        Site,
+        'group',
+        'site_count',
+        cumulative=True
+    )
+    filterset = filters.SiteGroupFilterSet
+    table = tables.SiteGroupTable
+    form = forms.SiteGroupBulkEditForm
+
+
 class SiteGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = SiteGroup.objects.add_related_count(
         SiteGroup.objects.all(),
@@ -279,6 +305,19 @@ class LocationBulkImportView(generic.BulkImportView):
     table = tables.LocationTable
 
 
+class LocationBulkEditView(generic.BulkEditView):
+    queryset = Location.objects.add_related_count(
+        Location.objects.all(),
+        Rack,
+        'location',
+        'rack_count',
+        cumulative=True
+    ).prefetch_related('site')
+    filterset = filters.LocationFilterSet
+    table = tables.LocationTable
+    form = forms.LocationBulkEditForm
+
+
 class LocationBulkDeleteView(generic.BulkDeleteView):
     queryset = Location.objects.add_related_count(
         Location.objects.all(),
@@ -317,6 +356,15 @@ class RackRoleBulkImportView(generic.BulkImportView):
     table = tables.RackRoleTable
 
 
+class RackRoleBulkEditView(generic.BulkEditView):
+    queryset = RackRole.objects.annotate(
+        rack_count=count_related(Rack, 'role')
+    )
+    filterset = filters.RackRoleFilterSet
+    table = tables.RackRoleTable
+    form = forms.RackRoleBulkEditForm
+
+
 class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
@@ -534,6 +582,15 @@ class ManufacturerBulkImportView(generic.BulkImportView):
     table = tables.ManufacturerTable
 
 
+class ManufacturerBulkEditView(generic.BulkEditView):
+    queryset = Manufacturer.objects.annotate(
+        devicetype_count=count_related(DeviceType, 'manufacturer')
+    )
+    filterset = filters.ManufacturerFilterSet
+    table = tables.ManufacturerTable
+    form = forms.ManufacturerBulkEditForm
+
+
 class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer')
@@ -975,6 +1032,13 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
     table = tables.DeviceRoleTable
 
 
+class DeviceRoleBulkEditView(generic.BulkEditView):
+    queryset = DeviceRole.objects.all()
+    filterset = filters.DeviceRoleFilterSet
+    table = tables.DeviceRoleTable
+    form = forms.DeviceRoleBulkEditForm
+
+
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
@@ -1007,6 +1071,13 @@ class PlatformBulkImportView(generic.BulkImportView):
     table = tables.PlatformTable
 
 
+class PlatformBulkEditView(generic.BulkEditView):
+    queryset = Platform.objects.all()
+    filterset = filters.PlatformFilterSet
+    table = tables.PlatformTable
+    form = forms.PlatformBulkEditForm
+
+
 class PlatformBulkDeleteView(generic.BulkDeleteView):
     queryset = Platform.objects.all()
     table = tables.PlatformTable

+ 2 - 9
netbox/extras/api/nested_serializers.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 from extras import choices, models
 from netbox.api import ChoiceField, WritableNestedSerializer
+from netbox.api.serializers import NestedTagSerializer
 from users.api.nested_serializers import NestedUserSerializer
 
 __all__ = [
@@ -11,7 +12,7 @@ __all__ = [
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
-    'NestedTagSerializer',
+    'NestedTagSerializer',  # Defined in netbox.api.serializers
     'NestedWebhookSerializer',
 ]
 
@@ -64,14 +65,6 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'image']
 
 
-class NestedTagSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
-
-    class Meta:
-        model = models.Tag
-        fields = ['id', 'url', 'name', 'slug', 'color']
-
-
 class NestedJobResultSerializer(serializers.ModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
     status = ChoiceField(choices=choices.JobResultStatusChoices)

+ 0 - 34
netbox/extras/api/serializers.py

@@ -21,7 +21,6 @@ from virtualization.api.nested_serializers import NestedClusterGroupSerializer,
 from virtualization.models import Cluster, ClusterGroup
 from .nested_serializers import *
 
-
 __all__ = (
     'ConfigContextSerializer',
     'ContentTypeSerializer',
@@ -39,7 +38,6 @@ __all__ = (
     'ScriptOutputSerializer',
     'ScriptSerializer',
     'TagSerializer',
-    'TaggedObjectSerializer',
     'WebhookSerializer',
 )
 
@@ -131,38 +129,6 @@ class TagSerializer(ValidatedModelSerializer):
         fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
 
 
-class TaggedObjectSerializer(serializers.Serializer):
-    tags = NestedTagSerializer(many=True, required=False)
-
-    def create(self, validated_data):
-        tags = validated_data.pop('tags', None)
-        instance = super().create(validated_data)
-
-        if tags is not None:
-            return self._save_tags(instance, tags)
-        return instance
-
-    def update(self, instance, validated_data):
-        tags = validated_data.pop('tags', None)
-
-        # Cache tags on instance for change logging
-        instance._tags = tags or []
-
-        instance = super().update(instance, validated_data)
-
-        if tags is not None:
-            return self._save_tags(instance, tags)
-        return instance
-
-    def _save_tags(self, instance, tags):
-        if tags:
-            instance.tags.set(*[t.name for t in tags])
-        else:
-            instance.tags.clear()
-
-        return instance
-
-
 #
 # Image attachments
 #

+ 8 - 9
netbox/ipam/api/serializers.py

@@ -6,13 +6,12 @@ from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
 from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import OrganizationalModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -23,7 +22,7 @@ from .nested_serializers import *
 # VRFs
 #
 
-class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VRFSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     import_targets = SerializedPKRelatedField(
@@ -53,7 +52,7 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Route targets
 #
 
-class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RouteTargetSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
 
@@ -80,7 +79,7 @@ class RIRSerializer(OrganizationalModelSerializer):
         ]
 
 
-class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class AggregateSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
@@ -154,7 +153,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
         return serializer(obj.scope, context=context).data
 
 
-class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VLANSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
     site = NestedSiteSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
@@ -189,7 +188,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Prefixes
 #
 
-class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class PrefixSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
@@ -259,7 +258,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
 # IP addresses
 #
 
-class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class IPAddressSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -317,7 +316,7 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 #
 
-class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class ServiceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)

+ 53 - 0
netbox/ipam/forms.py

@@ -217,6 +217,24 @@ class RIRCSVForm(CustomFieldModelCSVForm):
         }
 
 
+class RIRBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RIR.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    is_private = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['is_private', 'description']
+
+
 class RIRFilterForm(BootstrapMixin, forms.Form):
     is_private = forms.NullBooleanField(
         required=False,
@@ -351,6 +369,23 @@ class RoleCSVForm(CustomFieldModelCSVForm):
         fields = Role.csv_headers
 
 
+class RoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Role.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 #
 # Prefixes
 #
@@ -1223,6 +1258,24 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
         fields = VLANGroup.csv_headers
 
 
+class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VLANGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['site', 'description']
+
+
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),

+ 12 - 0
netbox/ipam/tests/test_views.py

@@ -118,6 +118,10 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "RIR 6,rir-6,Sixth RIR",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Aggregate
@@ -187,6 +191,10 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Role 6,role-6,1000",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Prefix
@@ -332,6 +340,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "VLAN Group 6,vlan-group-6,Sixth VLAN group",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VLAN

+ 3 - 0
netbox/ipam/urls.py

@@ -33,6 +33,7 @@ urlpatterns = [
     path('rirs/', views.RIRListView.as_view(), name='rir_list'),
     path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
     path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
+    path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'),
     path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
     path('rirs/<int:pk>/edit/', views.RIREditView.as_view(), name='rir_edit'),
     path('rirs/<int:pk>/delete/', views.RIRDeleteView.as_view(), name='rir_delete'),
@@ -53,6 +54,7 @@ urlpatterns = [
     path('roles/', views.RoleListView.as_view(), name='role_list'),
     path('roles/add/', views.RoleEditView.as_view(), name='role_add'),
     path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
+    path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'),
     path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
     path('roles/<int:pk>/edit/', views.RoleEditView.as_view(), name='role_edit'),
     path('roles/<int:pk>/delete/', views.RoleDeleteView.as_view(), name='role_delete'),
@@ -88,6 +90,7 @@ urlpatterns = [
     path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
     path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
     path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
+    path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'),
     path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
     path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
     path('vlan-groups/<int:pk>/delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'),

+ 25 - 0
netbox/ipam/views.py

@@ -164,6 +164,15 @@ class RIRBulkImportView(generic.BulkImportView):
     table = tables.RIRTable
 
 
+class RIRBulkEditView(generic.BulkEditView):
+    queryset = RIR.objects.annotate(
+        aggregate_count=count_related(Aggregate, 'rir')
+    )
+    filterset = filters.RIRFilterSet
+    table = tables.RIRTable
+    form = forms.RIRBulkEditForm
+
+
 class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
@@ -298,6 +307,13 @@ class RoleBulkImportView(generic.BulkImportView):
     table = tables.RoleTable
 
 
+class RoleBulkEditView(generic.BulkEditView):
+    queryset = Role.objects.all()
+    filterset = filters.RoleFilterSet
+    table = tables.RoleTable
+    form = forms.RoleBulkEditForm
+
+
 class RoleBulkDeleteView(generic.BulkDeleteView):
     queryset = Role.objects.all()
     table = tables.RoleTable
@@ -655,6 +671,15 @@ class VLANGroupBulkImportView(generic.BulkImportView):
     table = tables.VLANGroupTable
 
 
+class VLANGroupBulkEditView(generic.BulkEditView):
+    queryset = VLANGroup.objects.prefetch_related('site').annotate(
+        vlan_count=count_related(VLAN, 'group')
+    )
+    filterset = filters.VLANGroupFilterSet
+    table = tables.VLANGroupTable
+    form = forms.VLANGroupBulkEditForm
+
+
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')

+ 70 - 9
netbox/netbox/api/serializers.py

@@ -6,7 +6,7 @@ from rest_framework.exceptions import ValidationError
 from rest_framework.fields import CreateOnlyDefault
 
 from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
-from extras.models import CustomField
+from extras.models import CustomField, Tag
 from utilities.utils import dict_to_filter_params
 
 
@@ -70,19 +70,14 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
             instance.custom_fields[field.name] = instance.cf.get(field.name)
 
 
-class OrganizationalModelSerializer(CustomFieldModelSerializer):
-    pass
-
-
-class NestedGroupModelSerializer(CustomFieldModelSerializer):
-    _depth = serializers.IntegerField(source='level', read_only=True)
-
+#
+# Nested serializers
+#
 
 class WritableNestedSerializer(serializers.ModelSerializer):
     """
     Returns a nested representation of an object on read, but accepts only a primary key on write.
     """
-
     def to_internal_value(self, data):
 
         if data is None:
@@ -128,5 +123,71 @@ class WritableNestedSerializer(serializers.ModelSerializer):
             )
 
 
+#
+# Nested tags serialization
+#
+
+# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
+class NestedTagSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
+
+    class Meta:
+        model = Tag
+        fields = ['id', 'url', 'name', 'slug', 'color']
+
+
+#
+# Base model serializers
+#
+
+class OrganizationalModelSerializer(CustomFieldModelSerializer):
+    """
+    Adds support for custom fields.
+    """
+    pass
+
+
+class PrimaryModelSerializer(CustomFieldModelSerializer):
+    """
+    Adds support for custom fields and tags.
+    """
+    tags = NestedTagSerializer(many=True, required=False)
+
+    def create(self, validated_data):
+        tags = validated_data.pop('tags', None)
+        instance = super().create(validated_data)
+
+        if tags is not None:
+            return self._save_tags(instance, tags)
+        return instance
+
+    def update(self, instance, validated_data):
+        tags = validated_data.pop('tags', None)
+
+        # Cache tags on instance for change logging
+        instance._tags = tags or []
+
+        instance = super().update(instance, validated_data)
+
+        if tags is not None:
+            return self._save_tags(instance, tags)
+        return instance
+
+    def _save_tags(self, instance, tags):
+        if tags:
+            instance.tags.set(*[t.name for t in tags])
+        else:
+            instance.tags.clear()
+
+        return instance
+
+
+class NestedGroupModelSerializer(CustomFieldModelSerializer):
+    """
+    Extends OrganizationalModelSerializer to include MPTT support.
+    """
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+
 class BulkOperationSerializer(serializers.Serializer):
     id = serializers.IntegerField()

+ 4 - 5
netbox/secrets/api/serializers.py

@@ -2,11 +2,10 @@ from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
+from netbox.api import ContentTypeField
+from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from secrets.constants import SECRET_ASSIGNMENT_MODELS
 from secrets.models import Secret, SecretRole
-from netbox.api import ContentTypeField, ValidatedModelSerializer
 from utilities.api import get_serializer_for_model
 from .nested_serializers import *
 
@@ -15,7 +14,7 @@ from .nested_serializers import *
 # Secrets
 #
 
-class SecretRoleSerializer(CustomFieldModelSerializer):
+class SecretRoleSerializer(OrganizationalModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
     secret_count = serializers.IntegerField(read_only=True)
 
@@ -26,7 +25,7 @@ class SecretRoleSerializer(CustomFieldModelSerializer):
         ]
 
 
-class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class SecretSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail')
     assigned_object_type = ContentTypeField(
         queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS)

+ 14 - 0
netbox/secrets/forms.py

@@ -59,6 +59,20 @@ class SecretRoleCSVForm(CustomFieldModelCSVForm):
         fields = SecretRole.csv_headers
 
 
+class SecretRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=SecretRole.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 #
 # Secrets
 #

+ 4 - 0
netbox/secrets/tests/test_views.py

@@ -34,6 +34,10 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Secret Role 6,secret-role-6",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 # TODO: Change base class to PrimaryObjectViewTestCase
 class SecretTestCase(

+ 1 - 0
netbox/secrets/urls.py

@@ -11,6 +11,7 @@ urlpatterns = [
     path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
     path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'),
     path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
+    path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'),
     path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
     path('secret-roles/<int:pk>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
     path('secret-roles/<int:pk>/delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'),

+ 9 - 0
netbox/secrets/views.py

@@ -48,6 +48,15 @@ class SecretRoleBulkImportView(generic.BulkImportView):
     table = tables.SecretRoleTable
 
 
+class SecretRoleBulkEditView(generic.BulkEditView):
+    queryset = SecretRole.objects.annotate(
+        secret_count=count_related(Secret, 'role')
+    )
+    filterset = filters.SecretRoleFilterSet
+    table = tables.SecretRoleTable
+    form = forms.SecretRoleBulkEditForm
+
+
 class SecretRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = SecretRole.objects.annotate(
         secret_count=count_related(Secret, 'role')

+ 2 - 3
netbox/tenancy/api/serializers.py

@@ -1,7 +1,6 @@
 from rest_framework import serializers
 
-from netbox.api.serializers import CustomFieldModelSerializer, NestedGroupModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
 from tenancy.models import Tenant, TenantGroup
 from .nested_serializers import *
 
@@ -23,7 +22,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
         ]
 
 
-class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class TenantSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
     group = NestedTenantGroupSerializer(required=False, allow_null=True)
     circuit_count = serializers.IntegerField(read_only=True)

+ 18 - 0
netbox/tenancy/forms.py

@@ -44,6 +44,24 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm):
         fields = TenantGroup.csv_headers
 
 
+class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    parent = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['parent', 'description']
+
+
 #
 # Tenants
 #

+ 4 - 0
netbox/tenancy/tests/test_views.py

@@ -29,6 +29,10 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Tenant Group 6,tenant-group-6,Sixth tenant group",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Tenant

+ 1 - 0
netbox/tenancy/urls.py

@@ -11,6 +11,7 @@ urlpatterns = [
     path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
     path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
     path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
+    path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'),
     path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
     path('tenant-groups/<int:pk>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
     path('tenant-groups/<int:pk>/delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'),

+ 13 - 0
netbox/tenancy/views.py

@@ -39,6 +39,19 @@ class TenantGroupBulkImportView(generic.BulkImportView):
     table = tables.TenantGroupTable
 
 
+class TenantGroupBulkEditView(generic.BulkEditView):
+    queryset = TenantGroup.objects.add_related_count(
+        TenantGroup.objects.all(),
+        Tenant,
+        'group',
+        'tenant_count',
+        cumulative=True
+    )
+    filterset = filters.TenantGroupFilterSet
+    table = tables.TenantGroupTable
+    form = forms.TenantGroupBulkEditForm
+
+
 class TenantGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),

+ 1 - 0
netbox/utilities/testing/views.py

@@ -1024,6 +1024,7 @@ class ViewTestCases:
         DeleteObjectViewTestCase,
         ListObjectsViewTestCase,
         BulkImportObjectsViewTestCase,
+        BulkEditObjectsViewTestCase,
         BulkDeleteObjectsViewTestCase,
     ):
         """

+ 4 - 6
netbox/virtualization/api/serializers.py

@@ -3,12 +3,10 @@ from rest_framework import serializers
 
 from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
 from dcim.choices import InterfaceModeChoices
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from netbox.api import ChoiceField, SerializedPKRelatedField
-from netbox.api.serializers import OrganizationalModelSerializer, ValidatedModelSerializer
+from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -41,7 +39,7 @@ class ClusterGroupSerializer(OrganizationalModelSerializer):
         ]
 
 
-class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class ClusterSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
@@ -62,7 +60,7 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Virtual machines
 #
 
-class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VirtualMachineSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
@@ -103,7 +101,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 # VM interfaces
 #
 
-class VMInterfaceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VMInterfaceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
     virtual_machine = NestedVirtualMachineSerializer()
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)

+ 28 - 0
netbox/virtualization/forms.py

@@ -46,6 +46,20 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm):
         fields = ClusterType.csv_headers
 
 
+class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ClusterType.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 #
 # Cluster groups
 #
@@ -68,6 +82,20 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm):
         fields = ClusterGroup.csv_headers
 
 
+class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 #
 # Clusters
 #

+ 8 - 0
netbox/virtualization/tests/test_views.py

@@ -33,6 +33,10 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Cluster Group 6,cluster-group-6,Sixth cluster group",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = ClusterType
@@ -59,6 +63,10 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Cluster Type 6,cluster-type-6,Sixth cluster type",
         )
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Cluster

+ 2 - 0
netbox/virtualization/urls.py

@@ -12,6 +12,7 @@ urlpatterns = [
     path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
     path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'),
     path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
+    path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'),
     path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
     path('cluster-types/<int:pk>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
     path('cluster-types/<int:pk>/delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'),
@@ -21,6 +22,7 @@ urlpatterns = [
     path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
     path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'),
     path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
+    path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'),
     path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
     path('cluster-groups/<int:pk>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
     path('cluster-groups/<int:pk>/delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'),

+ 21 - 1
netbox/virtualization/views.py

@@ -42,6 +42,15 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
     table = tables.ClusterTypeTable
 
 
+class ClusterTypeBulkEditView(generic.BulkEditView):
+    queryset = ClusterType.objects.annotate(
+        cluster_count=count_related(Cluster, 'type')
+    )
+    filterset = filters.ClusterTypeFilterSet
+    table = tables.ClusterTypeTable
+    form = forms.ClusterTypeBulkEditForm
+
+
 class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
@@ -70,11 +79,22 @@ class ClusterGroupDeleteView(generic.ObjectDeleteView):
 
 
 class ClusterGroupBulkImportView(generic.BulkImportView):
-    queryset = ClusterGroup.objects.all()
+    queryset = ClusterGroup.objects.annotate(
+        cluster_count=count_related(Cluster, 'group')
+    )
     model_form = forms.ClusterGroupCSVForm
     table = tables.ClusterGroupTable
 
 
+class ClusterGroupBulkEditView(generic.BulkEditView):
+    queryset = ClusterGroup.objects.annotate(
+        cluster_count=count_related(Cluster, 'group')
+    )
+    filterset = filters.ClusterGroupFilterSet
+    table = tables.ClusterGroupTable
+    form = forms.ClusterGroupBulkEditForm
+
+
 class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')