Bladeren bron

Merge branch 'feature' into 5284-vlangroup-scope

Jeremy Stretch 4 jaren geleden
bovenliggende
commit
10778f8479
38 gewijzigde bestanden met toevoegingen van 760 en 170 verwijderingen
  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
 * [#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
 * [#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
 * [#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
 ### 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 circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
 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 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 tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 from .nested_serializers import *
 
 
@@ -16,7 +14,7 @@ from .nested_serializers import *
 # Providers
 # Providers
 #
 #
 
 
-class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class ProviderSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
     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')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     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):
 class CircuitTypeCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 

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

@@ -73,6 +73,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Circuit Type 6,circuit-type-6",
             "Circuit Type 6,circuit-type-6",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'Foo',
+        }
+
 
 
 class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Circuit
     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/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
     path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
     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/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/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>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
     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
     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):
 class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
         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.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models 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.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
 from netbox.api.serializers import (
 from netbox.api.serializers import (
-    NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
+    NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
+    WritableNestedSerializer,
 )
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -43,7 +42,7 @@ class CableTerminationSerializer(serializers.ModelSerializer):
         return None
         return None
 
 
 
 
-class ConnectedEndpointSerializer(CustomFieldModelSerializer):
+class ConnectedEndpointSerializer(serializers.ModelSerializer):
     connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
     connected_endpoint = serializers.SerializerMethodField(read_only=True)
     connected_endpoint = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_reachable = 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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = NestedRegionSerializer(required=False, allow_null=True)
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
@@ -206,7 +205,7 @@ class RackUnitSerializer(serializers.Serializer):
     occupied = serializers.BooleanField(read_only=True)
     occupied = serializers.BooleanField(read_only=True)
 
 
 
 
-class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RackReservationSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
     rack = NestedRackSerializer()
     rack = NestedRackSerializer()
     user = NestedUserSerializer()
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer()
     manufacturer = NestedManufacturerSerializer()
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
     device_role = NestedDeviceRoleSerializer()
@@ -506,7 +505,11 @@ class DeviceNAPALMSerializer(serializers.Serializer):
     method = serializers.DictField()
     method = serializers.DictField()
 
 
 
 
-class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+#
+# Device components
+#
+
+class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
     type = ChoiceField(choices=InterfaceTypeChoices)
@@ -643,7 +646,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
         return super().validate(data)
         return super().validate(data)
 
 
 
 
-class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
+class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
@@ -668,7 +671,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'label']
         fields = ['id', 'url', 'name', 'label']
 
 
 
 
-class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
+class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
     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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
@@ -701,7 +704,7 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Inventory items
 # Inventory items
 #
 #
 
 
-class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class InventoryItemSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     # Provide a default value to satisfy UniqueTogetherValidator
     # Provide a default value to satisfy UniqueTogetherValidator
@@ -721,7 +724,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer
 # Cables
 # Cables
 #
 #
 
 
-class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class CableSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     termination_a_type = ContentTypeField(
     termination_a_type = ContentTypeField(
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@@ -851,7 +854,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VirtualChassisSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer(required=False)
     master = NestedDeviceSerializer(required=False)
     member_count = serializers.IntegerField(read_only=True)
     member_count = serializers.IntegerField(read_only=True)
@@ -865,7 +868,7 @@ class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerialize
 # Power panels
 # Power panels
 #
 #
 
 
-class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class PowerPanelSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
     location = NestedLocationSerializer(
     location = NestedLocationSerializer(
@@ -880,12 +883,7 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
         fields = ['id', 'url', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
         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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(
     rack = NestedRackSerializer(

+ 141 - 0
netbox/dcim/forms.py

@@ -201,6 +201,24 @@ class RegionCSVForm(CustomFieldModelCSVForm):
         fields = Region.csv_headers
         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):
 class RegionFilterForm(BootstrapMixin, forms.Form):
     model = Site
     model = Site
     q = forms.CharField(
     q = forms.CharField(
@@ -240,6 +258,24 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm):
         fields = SiteGroup.csv_headers
         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):
 class SiteGroupFilterForm(BootstrapMixin, forms.Form):
     model = Site
     model = Site
     q = forms.CharField(
     q = forms.CharField(
@@ -480,6 +516,31 @@ class LocationCSVForm(CustomFieldModelCSVForm):
         fields = Location.csv_headers
         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):
 class LocationFilterForm(BootstrapMixin, forms.Form):
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         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
 # Racks
 #
 #
@@ -1026,6 +1106,20 @@ class ManufacturerCSVForm(CustomFieldModelCSVForm):
         fields = Manufacturer.csv_headers
         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
 # 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
 # Platforms
 #
 #
@@ -1859,6 +1977,29 @@ class PlatformCSVForm(CustomFieldModelCSVForm):
         fields = Platform.csv_headers
         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
 # 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 import models
 from django.db.models import Count, Sum
 from django.db.models import Count, Sum
 from django.urls import reverse
 from django.urls import reverse
-from mptt.models import TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.elevations import RackElevationSVG
 from dcim.elevations import RackElevationSVG
 from extras.utils import extras_features
 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.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -27,7 +26,6 @@ from .power import PowerFeed
 
 
 __all__ = (
 __all__ = (
     'Rack',
     'Rack',
-    'Location',
     'RackReservation',
     'RackReservation',
     'RackRole',
     'RackRole',
 )
 )
@@ -37,65 +35,6 @@ __all__ = (
 # Racks
 # 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')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 class RackRole(OrganizationalModel):
 class RackRole(OrganizationalModel):
     """
     """

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

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import TreeForeignKey
 from mptt.models import TreeForeignKey
@@ -13,6 +14,7 @@ from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
 __all__ = (
 __all__ = (
+    'Location',
     'Region',
     'Region',
     'Site',
     'Site',
     'SiteGroup',
     'SiteGroup',
@@ -276,3 +278,66 @@ class Site(PrimaryModel):
 
 
     def get_status_class(self):
     def get_status_class(self):
         return SiteStatusChoices.CSS_CLASSES.get(self.status)
         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)
         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):
 class SiteTestCase(TestCase):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
     filterset = SiteFilterSet
     filterset = SiteFilterSet

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

@@ -57,6 +57,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Region 6,region-6,Sixth region",
             "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):
 class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Site
     model = Site
@@ -157,6 +195,10 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Site 1,Location 6,location-6,Sixth location",
             "Site 1,Location 6,location-6,Sixth location",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RackRole
     model = RackRole
@@ -184,6 +226,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Rack Role 6,rack-role-6,0000ff",
             "Rack Role 6,rack-role-6,0000ff",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
 
 
 class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = RackReservation
     model = RackReservation
@@ -345,6 +392,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Manufacturer 6,manufacturer-6,Sixth manufacturer",
             "Manufacturer 6,manufacturer-6,Sixth manufacturer",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 # TODO: Change base class to PrimaryObjectViewTestCase
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of bulk import view for DeviceTypes
 # Blocked by absence of bulk import view for DeviceTypes
@@ -894,6 +945,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Device Role 6,device-role-6,0000ff",
             "Device Role 6,device-role-6,0000ff",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
 
 
 class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Platform
     model = Platform
@@ -925,6 +981,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Platform 6,platform-6,Sixth platform",
             "Platform 6,platform-6,Sixth platform",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'napalm_driver': 'ios',
+            'description': 'New description',
+        }
+
 
 
 class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Device
     model = Device

+ 7 - 0
netbox/dcim/urls.py

@@ -12,6 +12,7 @@ urlpatterns = [
     path('regions/', views.RegionListView.as_view(), name='region_list'),
     path('regions/', views.RegionListView.as_view(), name='region_list'),
     path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
     path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
     path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
     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/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>/edit/', views.RegionEditView.as_view(), name='region_edit'),
     path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
     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/', views.SiteGroupListView.as_view(), name='sitegroup_list'),
     path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'),
     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/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/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>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
     path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
     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/', views.LocationListView.as_view(), name='location_list'),
     path('locations/add/', views.LocationEditView.as_view(), name='location_add'),
     path('locations/add/', views.LocationEditView.as_view(), name='location_add'),
     path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'),
     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/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>/edit/', views.LocationEditView.as_view(), name='location_edit'),
     path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
     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/', views.RackRoleListView.as_view(), name='rackrole_list'),
     path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
     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/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/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>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
     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/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
     path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
     path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
     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/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>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
     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/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
     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/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/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>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
     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/', views.PlatformListView.as_view(), name='platform_list'),
     path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
     path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
     path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
     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/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>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
     path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
     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
     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):
 class RegionBulkDeleteView(generic.BulkDeleteView):
     queryset = Region.objects.add_related_count(
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Region.objects.all(),
@@ -170,6 +183,19 @@ class SiteGroupBulkImportView(generic.BulkImportView):
     table = tables.SiteGroupTable
     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):
 class SiteGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = SiteGroup.objects.add_related_count(
     queryset = SiteGroup.objects.add_related_count(
         SiteGroup.objects.all(),
         SiteGroup.objects.all(),
@@ -279,6 +305,19 @@ class LocationBulkImportView(generic.BulkImportView):
     table = tables.LocationTable
     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):
 class LocationBulkDeleteView(generic.BulkDeleteView):
     queryset = Location.objects.add_related_count(
     queryset = Location.objects.add_related_count(
         Location.objects.all(),
         Location.objects.all(),
@@ -317,6 +356,15 @@ class RackRoleBulkImportView(generic.BulkImportView):
     table = tables.RackRoleTable
     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):
 class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
         rack_count=count_related(Rack, 'role')
@@ -534,6 +582,15 @@ class ManufacturerBulkImportView(generic.BulkImportView):
     table = tables.ManufacturerTable
     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):
 class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer')
         devicetype_count=count_related(DeviceType, 'manufacturer')
@@ -975,6 +1032,13 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
 
 
 
 
+class DeviceRoleBulkEditView(generic.BulkEditView):
+    queryset = DeviceRole.objects.all()
+    filterset = filters.DeviceRoleFilterSet
+    table = tables.DeviceRoleTable
+    form = forms.DeviceRoleBulkEditForm
+
+
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
@@ -1007,6 +1071,13 @@ class PlatformBulkImportView(generic.BulkImportView):
     table = tables.PlatformTable
     table = tables.PlatformTable
 
 
 
 
+class PlatformBulkEditView(generic.BulkEditView):
+    queryset = Platform.objects.all()
+    filterset = filters.PlatformFilterSet
+    table = tables.PlatformTable
+    form = forms.PlatformBulkEditForm
+
+
 class PlatformBulkDeleteView(generic.BulkDeleteView):
 class PlatformBulkDeleteView(generic.BulkDeleteView):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     table = tables.PlatformTable
     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 extras import choices, models
 from netbox.api import ChoiceField, WritableNestedSerializer
 from netbox.api import ChoiceField, WritableNestedSerializer
+from netbox.api.serializers import NestedTagSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
 
 
 __all__ = [
 __all__ = [
@@ -11,7 +12,7 @@ __all__ = [
     'NestedExportTemplateSerializer',
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
     'NestedJobResultSerializer',
-    'NestedTagSerializer',
+    'NestedTagSerializer',  # Defined in netbox.api.serializers
     'NestedWebhookSerializer',
     'NestedWebhookSerializer',
 ]
 ]
 
 
@@ -64,14 +65,6 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'image']
         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):
 class NestedJobResultSerializer(serializers.ModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
     status = ChoiceField(choices=choices.JobResultStatusChoices)
     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 virtualization.models import Cluster, ClusterGroup
 from .nested_serializers import *
 from .nested_serializers import *
 
 
-
 __all__ = (
 __all__ = (
     'ConfigContextSerializer',
     'ConfigContextSerializer',
     'ContentTypeSerializer',
     'ContentTypeSerializer',
@@ -39,7 +38,6 @@ __all__ = (
     'ScriptOutputSerializer',
     'ScriptOutputSerializer',
     'ScriptSerializer',
     'ScriptSerializer',
     'TagSerializer',
     'TagSerializer',
-    'TaggedObjectSerializer',
     'WebhookSerializer',
     'WebhookSerializer',
 )
 )
 
 
@@ -131,38 +129,6 @@ class TagSerializer(ValidatedModelSerializer):
         fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
         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
 # 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 rest_framework.validators import UniqueTogetherValidator
 
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 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.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import OrganizationalModelSerializer
 from netbox.api.serializers import OrganizationalModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -23,7 +22,7 @@ from .nested_serializers import *
 # VRFs
 # VRFs
 #
 #
 
 
-class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VRFSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     import_targets = SerializedPKRelatedField(
     import_targets = SerializedPKRelatedField(
@@ -53,7 +52,7 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Route targets
 # Route targets
 #
 #
 
 
-class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RouteTargetSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     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')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
     rir = NestedRIRSerializer()
@@ -154,7 +153,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
         return serializer(obj.scope, context=context).data
         return serializer(obj.scope, context=context).data
 
 
 
 
-class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VLANSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
@@ -189,7 +188,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Prefixes
 # Prefixes
 #
 #
 
 
-class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class PrefixSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
@@ -259,7 +258,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
 # IP addresses
 # IP addresses
 #
 #
 
 
-class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class IPAddressSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -317,7 +316,7 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 # Services
 #
 #
 
 
-class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class ServiceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
     device = NestedDeviceSerializer(required=False, allow_null=True)
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(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):
 class RIRFilterForm(BootstrapMixin, forms.Form):
     is_private = forms.NullBooleanField(
     is_private = forms.NullBooleanField(
         required=False,
         required=False,
@@ -351,6 +369,23 @@ class RoleCSVForm(CustomFieldModelCSVForm):
         fields = Role.csv_headers
         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
 # Prefixes
 #
 #
@@ -1223,6 +1258,24 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
         fields = VLANGroup.csv_headers
         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):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     region = DynamicModelMultipleChoiceField(
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         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",
             "RIR 6,rir-6,Sixth RIR",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Aggregate
     model = Aggregate
@@ -187,6 +191,10 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Role 6,role-6,1000",
             "Role 6,role-6,1000",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Prefix
     model = Prefix
@@ -332,6 +340,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "VLAN Group 6,vlan-group-6,Sixth VLAN group",
             "VLAN Group 6,vlan-group-6,Sixth VLAN group",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VLAN
     model = VLAN

+ 3 - 0
netbox/ipam/urls.py

@@ -33,6 +33,7 @@ urlpatterns = [
     path('rirs/', views.RIRListView.as_view(), name='rir_list'),
     path('rirs/', views.RIRListView.as_view(), name='rir_list'),
     path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
     path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
     path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
     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/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>/edit/', views.RIREditView.as_view(), name='rir_edit'),
     path('rirs/<int:pk>/delete/', views.RIRDeleteView.as_view(), name='rir_delete'),
     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/', views.RoleListView.as_view(), name='role_list'),
     path('roles/add/', views.RoleEditView.as_view(), name='role_add'),
     path('roles/add/', views.RoleEditView.as_view(), name='role_add'),
     path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
     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/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>/edit/', views.RoleEditView.as_view(), name='role_edit'),
     path('roles/<int:pk>/delete/', views.RoleDeleteView.as_view(), name='role_delete'),
     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/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
     path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
     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/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/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>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
     path('vlan-groups/<int:pk>/delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'),
     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
     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):
 class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
         aggregate_count=count_related(Aggregate, 'rir')
@@ -298,6 +307,13 @@ class RoleBulkImportView(generic.BulkImportView):
     table = tables.RoleTable
     table = tables.RoleTable
 
 
 
 
+class RoleBulkEditView(generic.BulkEditView):
+    queryset = Role.objects.all()
+    filterset = filters.RoleFilterSet
+    table = tables.RoleTable
+    form = forms.RoleBulkEditForm
+
+
 class RoleBulkDeleteView(generic.BulkDeleteView):
 class RoleBulkDeleteView(generic.BulkDeleteView):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
     table = tables.RoleTable
     table = tables.RoleTable
@@ -655,6 +671,15 @@ class VLANGroupBulkImportView(generic.BulkImportView):
     table = tables.VLANGroupTable
     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):
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.annotate(
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         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 rest_framework.fields import CreateOnlyDefault
 
 
 from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
 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
 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)
             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):
 class WritableNestedSerializer(serializers.ModelSerializer):
     """
     """
     Returns a nested representation of an object on read, but accepts only a primary key on write.
     Returns a nested representation of an object on read, but accepts only a primary key on write.
     """
     """
-
     def to_internal_value(self, data):
     def to_internal_value(self, data):
 
 
         if data is None:
         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):
 class BulkOperationSerializer(serializers.Serializer):
     id = serializers.IntegerField()
     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 drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 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.constants import SECRET_ASSIGNMENT_MODELS
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
-from netbox.api import ContentTypeField, ValidatedModelSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from .nested_serializers import *
 from .nested_serializers import *
 
 
@@ -15,7 +14,7 @@ from .nested_serializers import *
 # Secrets
 # Secrets
 #
 #
 
 
-class SecretRoleSerializer(CustomFieldModelSerializer):
+class SecretRoleSerializer(OrganizationalModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
     secret_count = serializers.IntegerField(read_only=True)
     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')
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail')
     assigned_object_type = ContentTypeField(
     assigned_object_type = ContentTypeField(
         queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS)
         queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS)

+ 14 - 0
netbox/secrets/forms.py

@@ -59,6 +59,20 @@ class SecretRoleCSVForm(CustomFieldModelCSVForm):
         fields = SecretRole.csv_headers
         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
 # Secrets
 #
 #

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

@@ -34,6 +34,10 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Secret Role 6,secret-role-6",
             "Secret Role 6,secret-role-6",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 # TODO: Change base class to PrimaryObjectViewTestCase
 # TODO: Change base class to PrimaryObjectViewTestCase
 class SecretTestCase(
 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/', views.SecretRoleListView.as_view(), name='secretrole_list'),
     path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'),
     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/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/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>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
     path('secret-roles/<int:pk>/delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'),
     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
     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):
 class SecretRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = SecretRole.objects.annotate(
     queryset = SecretRole.objects.annotate(
         secret_count=count_related(Secret, 'role')
         secret_count=count_related(Secret, 'role')

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

@@ -1,7 +1,6 @@
 from rest_framework import serializers
 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 tenancy.models import Tenant, TenantGroup
 from .nested_serializers import *
 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')
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
     group = NestedTenantGroupSerializer(required=False, allow_null=True)
     group = NestedTenantGroupSerializer(required=False, allow_null=True)
     circuit_count = serializers.IntegerField(read_only=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
         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
 # 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",
             "Tenant Group 6,tenant-group-6,Sixth tenant group",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Tenant
     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/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
     path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
     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/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/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>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
     path('tenant-groups/<int:pk>/delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'),
     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
     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):
 class TenantGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = TenantGroup.objects.add_related_count(
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         TenantGroup.objects.all(),

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

@@ -1024,6 +1024,7 @@ class ViewTestCases:
         DeleteObjectViewTestCase,
         DeleteObjectViewTestCase,
         ListObjectsViewTestCase,
         ListObjectsViewTestCase,
         BulkImportObjectsViewTestCase,
         BulkImportObjectsViewTestCase,
+        BulkEditObjectsViewTestCase,
         BulkDeleteObjectsViewTestCase,
         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.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
 from dcim.choices import InterfaceModeChoices
 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.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api import ChoiceField, SerializedPKRelatedField
 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 tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 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')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
     type = NestedClusterTypeSerializer()
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
@@ -62,7 +60,7 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Virtual machines
 # Virtual machines
 #
 #
 
 
-class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VirtualMachineSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
     site = NestedSiteSerializer(read_only=True)
@@ -103,7 +101,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 # VM interfaces
 # VM interfaces
 #
 #
 
 
-class VMInterfaceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VMInterfaceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
     virtual_machine = NestedVirtualMachineSerializer()
     virtual_machine = NestedVirtualMachineSerializer()
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     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
         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
 # Cluster groups
 #
 #
@@ -68,6 +82,20 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm):
         fields = ClusterGroup.csv_headers
         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
 # 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",
             "Cluster Group 6,cluster-group-6,Sixth cluster group",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = ClusterType
     model = ClusterType
@@ -59,6 +63,10 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Cluster Type 6,cluster-type-6,Sixth cluster type",
             "Cluster Type 6,cluster-type-6,Sixth cluster type",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Cluster
     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/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
     path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'),
     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/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/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>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
     path('cluster-types/<int:pk>/delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'),
     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/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
     path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'),
     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/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/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>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
     path('cluster-groups/<int:pk>/delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'),
     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
     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):
 class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
         cluster_count=count_related(Cluster, 'type')
@@ -70,11 +79,22 @@ class ClusterGroupDeleteView(generic.ObjectDeleteView):
 
 
 
 
 class ClusterGroupBulkImportView(generic.BulkImportView):
 class ClusterGroupBulkImportView(generic.BulkImportView):
-    queryset = ClusterGroup.objects.all()
+    queryset = ClusterGroup.objects.annotate(
+        cluster_count=count_related(Cluster, 'group')
+    )
     model_form = forms.ClusterGroupCSVForm
     model_form = forms.ClusterGroupCSVForm
     table = tables.ClusterGroupTable
     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):
 class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
         cluster_count=count_related(Cluster, 'group')