diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 3eb918b5f..0e6055e3c 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -77,6 +77,7 @@ The ObjectChange model (which is used to record the creation, modification, and * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI * [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models +* [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models ### Other Changes diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index f6a9ae3a1..a0cf3c80c 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,10 +4,8 @@ from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from netbox.api import ChoiceField -from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -16,7 +14,7 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ProviderSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') circuit_count = serializers.IntegerField(read_only=True) @@ -55,7 +53,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd ] -class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CircuitSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index cffbf14ae..002c73b9a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -142,6 +142,20 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): ] +class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CircuitType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + class CircuitTypeCSVForm(CustomFieldModelCSVForm): slug = SlugField() diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 3356fca8f..de0d2c970 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -73,6 +73,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Circuit Type 6,circuit-type-6", ) + cls.bulk_edit_data = { + 'description': 'Foo', + } + class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index a8306efa8..964d5d59c 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index eec05dab8..b3215c029 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -107,6 +107,15 @@ class CircuitTypeBulkImportView(generic.BulkImportView): table = tables.CircuitTypeTable +class CircuitTypeBulkEditView(generic.BulkEditView): + queryset = CircuitType.objects.annotate( + circuit_count=count_related(Circuit, 'type') + ) + filterset = filters.CircuitTypeFilterSet + table = tables.CircuitTypeTable + form = forms.CircuitTypeBulkEditForm + + class CircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = CircuitType.objects.annotate( circuit_count=count_related(Circuit, 'type') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 642858d28..31db14f63 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,13 +7,12 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.choices import * from dcim.constants import * from dcim.models import * -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField from netbox.api.serializers import ( - NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, + NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, + WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer @@ -43,7 +42,7 @@ class CableTerminationSerializer(serializers.ModelSerializer): return None -class ConnectedEndpointSerializer(CustomFieldModelSerializer): +class ConnectedEndpointSerializer(serializers.ModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True) connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) @@ -101,7 +100,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): ] -class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class SiteSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) @@ -155,7 +154,7 @@ class RackRoleSerializer(OrganizationalModelSerializer): ] -class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RackSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') site = NestedSiteSerializer() location = NestedLocationSerializer(required=False, allow_null=True, default=None) @@ -206,7 +205,7 @@ class RackUnitSerializer(serializers.Serializer): occupied = serializers.BooleanField(read_only=True) -class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RackReservationSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() @@ -271,7 +270,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer): ] -class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) @@ -434,7 +433,7 @@ class PlatformSerializer(OrganizationalModelSerializer): ] -class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() @@ -506,7 +505,11 @@ class DeviceNAPALMSerializer(serializers.Serializer): method = serializers.DictField() -class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +# +# Device components +# + +class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -530,7 +533,7 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial ] -class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -554,7 +557,7 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ] -class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -583,7 +586,7 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ] -class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -602,7 +605,7 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co ] -class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) @@ -643,7 +646,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer): +class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -668,7 +671,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'label'] -class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer): +class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -684,7 +687,7 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cu ] -class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceBaySerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) @@ -701,7 +704,7 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Inventory items # -class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator @@ -721,7 +724,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer # Cables # -class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CableSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) @@ -851,7 +854,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VirtualChassisSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) @@ -865,7 +868,7 @@ class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerialize # Power panels # -class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class PowerPanelSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() location = NestedLocationSerializer( @@ -880,12 +883,7 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): fields = ['id', 'url', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer( - TaggedObjectSerializer, - CableTerminationSerializer, - ConnectedEndpointSerializer, - CustomFieldModelSerializer -): +class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 102c3114b..dcdde0d60 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -201,6 +201,24 @@ class RegionCSVForm(CustomFieldModelCSVForm): fields = Region.csv_headers +class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Region.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class RegionFilterForm(BootstrapMixin, forms.Form): model = Site q = forms.CharField( @@ -240,6 +258,24 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm): fields = SiteGroup.csv_headers +class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class SiteGroupFilterForm(BootstrapMixin, forms.Form): model = Site q = forms.CharField( @@ -480,6 +516,31 @@ class LocationCSVForm(CustomFieldModelCSVForm): fields = Location.csv_headers +class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Location.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + parent = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class LocationFilterForm(BootstrapMixin, forms.Form): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -530,6 +591,25 @@ class RackRoleCSVForm(CustomFieldModelCSVForm): } +class RackRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RackRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, # RGB color code + required=False, + widget=ColorSelect() + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + # # Racks # @@ -1026,6 +1106,20 @@ class ManufacturerCSVForm(CustomFieldModelCSVForm): fields = Manufacturer.csv_headers +class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Device types # @@ -1822,6 +1916,30 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm): } +class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, # RGB color code + required=False, + widget=ColorSelect() + ) + vm_role = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='VM role' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + # # Platforms # @@ -1859,6 +1977,29 @@ class PlatformCSVForm(CustomFieldModelCSVForm): fields = Platform.csv_headers +class PlatformBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Platform.objects.all(), + widget=forms.MultipleHiddenInput + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + napalm_driver = forms.CharField( + max_length=50, + required=False + ) + # TODO: Bulk edit support for napalm_args + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['manufacturer', 'napalm_driver', 'description'] + + # # Devices # diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 6031b47ac..1942e3cb0 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,13 +10,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Sum from django.urls import reverse -from mptt.models import TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.elevations import RackElevationSVG from extras.utils import extras_features -from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -27,7 +26,6 @@ from .power import PowerFeed __all__ = ( 'Rack', - 'Location', 'RackReservation', 'RackRole', ) @@ -37,65 +35,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'export_templates', 'webhooks') -class Location(NestedGroupModel): - """ - A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a - site, or a room within a building, for example. - """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.CASCADE, - related_name='locations' - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - csv_headers = ['site', 'parent', 'name', 'slug', 'description'] - - class Meta: - ordering = ['site', 'name'] - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - - def get_absolute_url(self): - return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk) - - def to_csv(self): - return ( - self.site, - self.parent.name if self.parent else '', - self.name, - self.slug, - self.description, - ) - - def clean(self): - super().clean() - - # Parent Location (if any) must belong to the same Site - if self.parent and self.parent.site != self.site: - raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})") - - @extras_features('custom_fields', 'export_templates', 'webhooks') class RackRole(OrganizationalModel): """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index eaa4ee861..51cb63d08 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey @@ -13,6 +14,7 @@ from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet __all__ = ( + 'Location', 'Region', 'Site', 'SiteGroup', @@ -276,3 +278,66 @@ class Site(PrimaryModel): def get_status_class(self): return SiteStatusChoices.CSS_CLASSES.get(self.status) + + +# +# Locations +# + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Location(NestedGroupModel): + """ + A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a + site, or a room within a building, for example. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='locations' + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + csv_headers = ['site', 'parent', 'name', 'slug', 'description'] + + class Meta: + ordering = ['site', 'name'] + unique_together = [ + ['site', 'name'], + ['site', 'slug'], + ] + + def get_absolute_url(self): + return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk) + + def to_csv(self): + return ( + self.site, + self.parent.name if self.parent else '', + self.name, + self.slug, + self.description, + ) + + def clean(self): + super().clean() + + # Parent Location (if any) must belong to the same Site + if self.parent and self.parent.site != self.site: + raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})") diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 67b9252c5..847618f41 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -59,6 +59,56 @@ class RegionTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class SiteGroupTestCase(TestCase): + queryset = SiteGroup.objects.all() + filterset = SiteGroupFilterSet + + @classmethod + def setUpTestData(cls): + + sitegroups = ( + SiteGroup(name='Site Group 1', slug='site-group-1', description='A'), + SiteGroup(name='Site Group 2', slug='site-group-2', description='B'), + SiteGroup(name='Site Group 3', slug='site-group-3', description='C'), + ) + for sitegroup in sitegroups: + sitegroup.save() + + child_sitegroups = ( + SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]), + SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]), + SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]), + SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]), + SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]), + SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]), + ) + for sitegroup in child_sitegroups: + sitegroup.save() + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Site Group 1', 'Site Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['site-group-1', 'site-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class SiteTestCase(TestCase): queryset = Site.objects.all() filterset = SiteFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 7dc57a3ea..8fde267d9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -57,6 +57,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Region 6,region-6,Sixth region", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = SiteGroup + + @classmethod + def setUpTestData(cls): + + # Create three SiteGroups + sitegroups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for sitegroup in sitegroups: + sitegroup.save() + + cls.form_data = { + 'name': 'Site Group X', + 'slug': 'site-group-x', + 'parent': sitegroups[2].pk, + 'description': 'A new site group', + } + + cls.csv_data = ( + "name,slug,description", + "Site Group 4,site-group-4,Fourth site group", + "Site Group 5,site-group-5,Fifth site group", + "Site Group 6,site-group-6,Sixth site group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Site @@ -157,6 +195,10 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Site 1,Location 6,location-6,Sixth location", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackRole @@ -184,6 +226,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Rack Role 6,rack-role-6,0000ff", ) + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = RackReservation @@ -345,6 +392,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Manufacturer 6,manufacturer-6,Sixth manufacturer", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + # TODO: Change base class to PrimaryObjectViewTestCase # Blocked by absence of bulk import view for DeviceTypes @@ -894,6 +945,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Device Role 6,device-role-6,0000ff", ) + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Platform @@ -925,6 +981,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Platform 6,platform-6,Sixth platform", ) + cls.bulk_edit_data = { + 'napalm_driver': 'ios', + 'description': 'New description', + } + class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Device diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 89d739743..290049010 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('regions/', views.RegionListView.as_view(), name='region_list'), path('regions/add/', views.RegionEditView.as_view(), name='region_add'), path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), @@ -21,6 +22,7 @@ urlpatterns = [ path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'), path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), + path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), @@ -42,6 +44,7 @@ urlpatterns = [ path('locations/', views.LocationListView.as_view(), name='location_list'), path('locations/add/', views.LocationEditView.as_view(), name='location_add'), path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), + path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), @@ -51,6 +54,7 @@ urlpatterns = [ path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'), path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), @@ -84,6 +88,7 @@ urlpatterns = [ path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), @@ -168,6 +173,7 @@ urlpatterns = [ path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), @@ -177,6 +183,7 @@ urlpatterns = [ path('platforms/', views.PlatformListView.as_view(), name='platform_list'), path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1a68ba932..da733843c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -126,6 +126,19 @@ class RegionBulkImportView(generic.BulkImportView): table = tables.RegionTable +class RegionBulkEditView(generic.BulkEditView): + queryset = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ) + filterset = filters.RegionFilterSet + table = tables.RegionTable + form = forms.RegionBulkEditForm + + class RegionBulkDeleteView(generic.BulkDeleteView): queryset = Region.objects.add_related_count( Region.objects.all(), @@ -170,6 +183,19 @@ class SiteGroupBulkImportView(generic.BulkImportView): table = tables.SiteGroupTable +class SiteGroupBulkEditView(generic.BulkEditView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filters.SiteGroupFilterSet + table = tables.SiteGroupTable + form = forms.SiteGroupBulkEditForm + + class SiteGroupBulkDeleteView(generic.BulkDeleteView): queryset = SiteGroup.objects.add_related_count( SiteGroup.objects.all(), @@ -279,6 +305,19 @@ class LocationBulkImportView(generic.BulkImportView): table = tables.LocationTable +class LocationBulkEditView(generic.BulkEditView): + queryset = Location.objects.add_related_count( + Location.objects.all(), + Rack, + 'location', + 'rack_count', + cumulative=True + ).prefetch_related('site') + filterset = filters.LocationFilterSet + table = tables.LocationTable + form = forms.LocationBulkEditForm + + class LocationBulkDeleteView(generic.BulkDeleteView): queryset = Location.objects.add_related_count( Location.objects.all(), @@ -317,6 +356,15 @@ class RackRoleBulkImportView(generic.BulkImportView): table = tables.RackRoleTable +class RackRoleBulkEditView(generic.BulkEditView): + queryset = RackRole.objects.annotate( + rack_count=count_related(Rack, 'role') + ) + filterset = filters.RackRoleFilterSet + table = tables.RackRoleTable + form = forms.RackRoleBulkEditForm + + class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') @@ -534,6 +582,15 @@ class ManufacturerBulkImportView(generic.BulkImportView): table = tables.ManufacturerTable +class ManufacturerBulkEditView(generic.BulkEditView): + queryset = Manufacturer.objects.annotate( + devicetype_count=count_related(DeviceType, 'manufacturer') + ) + filterset = filters.ManufacturerFilterSet + table = tables.ManufacturerTable + form = forms.ManufacturerBulkEditForm + + class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer') @@ -975,6 +1032,13 @@ class DeviceRoleBulkImportView(generic.BulkImportView): table = tables.DeviceRoleTable +class DeviceRoleBulkEditView(generic.BulkEditView): + queryset = DeviceRole.objects.all() + filterset = filters.DeviceRoleFilterSet + table = tables.DeviceRoleTable + form = forms.DeviceRoleBulkEditForm + + class DeviceRoleBulkDeleteView(generic.BulkDeleteView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable @@ -1007,6 +1071,13 @@ class PlatformBulkImportView(generic.BulkImportView): table = tables.PlatformTable +class PlatformBulkEditView(generic.BulkEditView): + queryset = Platform.objects.all() + filterset = filters.PlatformFilterSet + table = tables.PlatformTable + form = forms.PlatformBulkEditForm + + class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index f5f01d789..03ea33cc0 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from extras import choices, models from netbox.api import ChoiceField, WritableNestedSerializer +from netbox.api.serializers import NestedTagSerializer from users.api.nested_serializers import NestedUserSerializer __all__ = [ @@ -11,7 +12,7 @@ __all__ = [ 'NestedExportTemplateSerializer', 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', - 'NestedTagSerializer', + 'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedWebhookSerializer', ] @@ -64,14 +65,6 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'image'] -class NestedTagSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') - - class Meta: - model = models.Tag - fields = ['id', 'url', 'name', 'slug', 'color'] - - class NestedJobResultSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') status = ChoiceField(choices=choices.JobResultStatusChoices) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 25b37db7f..8ba1c0700 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -21,7 +21,6 @@ from virtualization.api.nested_serializers import NestedClusterGroupSerializer, from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * - __all__ = ( 'ConfigContextSerializer', 'ContentTypeSerializer', @@ -39,7 +38,6 @@ __all__ = ( 'ScriptOutputSerializer', 'ScriptSerializer', 'TagSerializer', - 'TaggedObjectSerializer', 'WebhookSerializer', ) @@ -131,38 +129,6 @@ class TagSerializer(ValidatedModelSerializer): fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items'] -class TaggedObjectSerializer(serializers.Serializer): - tags = NestedTagSerializer(many=True, required=False) - - def create(self, validated_data): - tags = validated_data.pop('tags', None) - instance = super().create(validated_data) - - if tags is not None: - return self._save_tags(instance, tags) - return instance - - def update(self, instance, validated_data): - tags = validated_data.pop('tags', None) - - # Cache tags on instance for change logging - instance._tags = tags or [] - - instance = super().update(instance, validated_data) - - if tags is not None: - return self._save_tags(instance, tags) - return instance - - def _save_tags(self, instance, tags): - if tags: - instance.tags.set(*[t.name for t in tags]) - else: - instance.tags.clear() - - return instance - - # # Image attachments # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f9d8a1652..503b48e3a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -6,13 +6,12 @@ from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import OrganizationalModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer @@ -23,7 +22,7 @@ from .nested_serializers import * # VRFs # -class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VRFSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) import_targets = SerializedPKRelatedField( @@ -53,7 +52,7 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Route targets # -class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RouteTargetSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -80,7 +79,7 @@ class RIRSerializer(OrganizationalModelSerializer): ] -class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class AggregateSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() @@ -154,7 +153,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer): return serializer(obj.scope, context=context).data -class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) @@ -189,7 +188,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Prefixes # -class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class PrefixSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) site = NestedSiteSerializer(required=False, allow_null=True) @@ -259,7 +258,7 @@ class AvailablePrefixSerializer(serializers.Serializer): # IP addresses # -class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class IPAddressSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) vrf = NestedVRFSerializer(required=False, allow_null=True) @@ -317,7 +316,7 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ServiceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index db7077848..5bd967ee1 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -217,6 +217,24 @@ class RIRCSVForm(CustomFieldModelCSVForm): } +class RIRBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RIR.objects.all(), + widget=forms.MultipleHiddenInput + ) + is_private = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['is_private', 'description'] + + class RIRFilterForm(BootstrapMixin, forms.Form): is_private = forms.NullBooleanField( required=False, @@ -351,6 +369,23 @@ class RoleCSVForm(CustomFieldModelCSVForm): fields = Role.csv_headers +class RoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Role.objects.all(), + widget=forms.MultipleHiddenInput + ) + weight = forms.IntegerField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Prefixes # @@ -1223,6 +1258,24 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm): fields = VLANGroup.csv_headers +class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VLANGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['site', 'description'] + + class VLANGroupFilterForm(BootstrapMixin, forms.Form): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 7d099e762..387bdd2b5 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -118,6 +118,10 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "RIR 6,rir-6,Sixth RIR", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Aggregate @@ -187,6 +191,10 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Role 6,role-6,1000", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Prefix @@ -332,6 +340,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "VLAN Group 6,vlan-group-6,Sixth VLAN group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VLAN diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index db0eec157..07bd2c69f 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('rirs/', views.RIRListView.as_view(), name='rir_list'), path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), + path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), @@ -53,6 +54,7 @@ urlpatterns = [ path('roles/', views.RoleListView.as_view(), name='role_list'), path('roles/add/', views.RoleEditView.as_view(), name='role_add'), path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), + path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), @@ -88,6 +90,7 @@ urlpatterns = [ path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), + path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 3f283a932..5de6f847a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -164,6 +164,15 @@ class RIRBulkImportView(generic.BulkImportView): table = tables.RIRTable +class RIRBulkEditView(generic.BulkEditView): + queryset = RIR.objects.annotate( + aggregate_count=count_related(Aggregate, 'rir') + ) + filterset = filters.RIRFilterSet + table = tables.RIRTable + form = forms.RIRBulkEditForm + + class RIRBulkDeleteView(generic.BulkDeleteView): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') @@ -298,6 +307,13 @@ class RoleBulkImportView(generic.BulkImportView): table = tables.RoleTable +class RoleBulkEditView(generic.BulkEditView): + queryset = Role.objects.all() + filterset = filters.RoleFilterSet + table = tables.RoleTable + form = forms.RoleBulkEditForm + + class RoleBulkDeleteView(generic.BulkDeleteView): queryset = Role.objects.all() table = tables.RoleTable @@ -655,6 +671,15 @@ class VLANGroupBulkImportView(generic.BulkImportView): table = tables.VLANGroupTable +class VLANGroupBulkEditView(generic.BulkEditView): + queryset = VLANGroup.objects.prefetch_related('site').annotate( + vlan_count=count_related(VLAN, 'group') + ) + filterset = filters.VLANGroupFilterSet + table = tables.VLANGroupTable + form = forms.VLANGroupBulkEditForm + + class VLANGroupBulkDeleteView(generic.BulkDeleteView): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index 18fc112c8..6be408de6 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -6,7 +6,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import CreateOnlyDefault from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues -from extras.models import CustomField +from extras.models import CustomField, Tag from utilities.utils import dict_to_filter_params @@ -70,19 +70,14 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields[field.name] = instance.cf.get(field.name) -class OrganizationalModelSerializer(CustomFieldModelSerializer): - pass - - -class NestedGroupModelSerializer(CustomFieldModelSerializer): - _depth = serializers.IntegerField(source='level', read_only=True) - +# +# Nested serializers +# class WritableNestedSerializer(serializers.ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ - def to_internal_value(self, data): if data is None: @@ -128,5 +123,71 @@ class WritableNestedSerializer(serializers.ModelSerializer): ) +# +# Nested tags serialization +# + +# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers +class NestedTagSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + + class Meta: + model = Tag + fields = ['id', 'url', 'name', 'slug', 'color'] + + +# +# Base model serializers +# + +class OrganizationalModelSerializer(CustomFieldModelSerializer): + """ + Adds support for custom fields. + """ + pass + + +class PrimaryModelSerializer(CustomFieldModelSerializer): + """ + Adds support for custom fields and tags. + """ + tags = NestedTagSerializer(many=True, required=False) + + def create(self, validated_data): + tags = validated_data.pop('tags', None) + instance = super().create(validated_data) + + if tags is not None: + return self._save_tags(instance, tags) + return instance + + def update(self, instance, validated_data): + tags = validated_data.pop('tags', None) + + # Cache tags on instance for change logging + instance._tags = tags or [] + + instance = super().update(instance, validated_data) + + if tags is not None: + return self._save_tags(instance, tags) + return instance + + def _save_tags(self, instance, tags): + if tags: + instance.tags.set(*[t.name for t in tags]) + else: + instance.tags.clear() + + return instance + + +class NestedGroupModelSerializer(CustomFieldModelSerializer): + """ + Extends OrganizationalModelSerializer to include MPTT support. + """ + _depth = serializers.IntegerField(source='level', read_only=True) + + class BulkOperationSerializer(serializers.Serializer): id = serializers.IntegerField() diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 207836c4c..a17d70d4e 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -2,11 +2,10 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ContentTypeField +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole -from netbox.api import ContentTypeField, ValidatedModelSerializer from utilities.api import get_serializer_for_model from .nested_serializers import * @@ -15,7 +14,7 @@ from .nested_serializers import * # Secrets # -class SecretRoleSerializer(CustomFieldModelSerializer): +class SecretRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') secret_count = serializers.IntegerField(read_only=True) @@ -26,7 +25,7 @@ class SecretRoleSerializer(CustomFieldModelSerializer): ] -class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class SecretSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail') assigned_object_type = ContentTypeField( queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 9094d977c..6cb7a377b 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -59,6 +59,20 @@ class SecretRoleCSVForm(CustomFieldModelCSVForm): fields = SecretRole.csv_headers +class SecretRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SecretRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Secrets # diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index bff039656..5be77d418 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -34,6 +34,10 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Secret Role 6,secret-role-6", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + # TODO: Change base class to PrimaryObjectViewTestCase class SecretTestCase( diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 62a285875..7352a7de0 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), + path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 3fb8d1740..0ae2f6231 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -48,6 +48,15 @@ class SecretRoleBulkImportView(generic.BulkImportView): table = tables.SecretRoleTable +class SecretRoleBulkEditView(generic.BulkEditView): + queryset = SecretRole.objects.annotate( + secret_count=count_related(Secret, 'role') + ) + filterset = filters.SecretRoleFilterSet + table = tables.SecretRoleTable + form = forms.SecretRoleBulkEditForm + + class SecretRoleBulkDeleteView(generic.BulkDeleteView): queryset = SecretRole.objects.annotate( secret_count=count_related(Secret, 'role') diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index c701e6b3b..96eb63a60 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,6 @@ from rest_framework import serializers -from netbox.api.serializers import CustomFieldModelSerializer, NestedGroupModelSerializer -from extras.api.serializers import TaggedObjectSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.models import Tenant, TenantGroup from .nested_serializers import * @@ -23,7 +22,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer): ] -class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class TenantSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') group = NestedTenantGroupSerializer(required=False, allow_null=True) circuit_count = serializers.IntegerField(read_only=True) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 43401baab..d53748055 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -44,6 +44,24 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm): fields = TenantGroup.csv_headers +class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + # # Tenants # diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 5b88b84cf..8ef7efb12 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -29,6 +29,10 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tenant Group 6,tenant-group-6,Sixth tenant group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tenant diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index c07df97a6..c54c7fee9 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), + path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4c72cea42..11f5ead00 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -39,6 +39,19 @@ class TenantGroupBulkImportView(generic.BulkImportView): table = tables.TenantGroupTable +class TenantGroupBulkEditView(generic.BulkEditView): + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True + ) + filterset = filters.TenantGroupFilterSet + table = tables.TenantGroupTable + form = forms.TenantGroupBulkEditForm + + class TenantGroupBulkDeleteView(generic.BulkDeleteView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 3b4eca99a..35492a26b 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1024,6 +1024,7 @@ class ViewTestCases: DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): """ diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index f2640811b..8f49e2b50 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,12 +3,10 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer, ValidatedModelSerializer +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -41,7 +39,7 @@ class ClusterGroupSerializer(OrganizationalModelSerializer): ] -class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ClusterSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) @@ -62,7 +60,7 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Virtual machines # -class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VirtualMachineSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) @@ -103,7 +101,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): # VM interfaces # -class VMInterfaceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2aed5230f..36b581dcc 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -46,6 +46,20 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm): fields = ClusterType.csv_headers +class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Cluster groups # @@ -68,6 +82,20 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm): fields = ClusterGroup.csv_headers +class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Clusters # diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index e35f977fd..cb0055789 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -33,6 +33,10 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Group 6,cluster-group-6,Sixth cluster group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterType @@ -59,6 +63,10 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Type 6,cluster-type-6,Sixth cluster type", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cluster diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 703f99b65..e0565a5e6 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'), path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), + path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), @@ -21,6 +22,7 @@ urlpatterns = [ path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'), path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), + path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index e5fe05858..d0bb0cf76 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -42,6 +42,15 @@ class ClusterTypeBulkImportView(generic.BulkImportView): table = tables.ClusterTypeTable +class ClusterTypeBulkEditView(generic.BulkEditView): + queryset = ClusterType.objects.annotate( + cluster_count=count_related(Cluster, 'type') + ) + filterset = filters.ClusterTypeFilterSet + table = tables.ClusterTypeTable + form = forms.ClusterTypeBulkEditForm + + class ClusterTypeBulkDeleteView(generic.BulkDeleteView): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') @@ -70,11 +79,22 @@ class ClusterGroupDeleteView(generic.ObjectDeleteView): class ClusterGroupBulkImportView(generic.BulkImportView): - queryset = ClusterGroup.objects.all() + queryset = ClusterGroup.objects.annotate( + cluster_count=count_related(Cluster, 'group') + ) model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable +class ClusterGroupBulkEditView(generic.BulkEditView): + queryset = ClusterGroup.objects.annotate( + cluster_count=count_related(Cluster, 'group') + ) + filterset = filters.ClusterGroupFilterSet + table = tables.ClusterGroupTable + form = forms.ClusterGroupBulkEditForm + + class ClusterGroupBulkDeleteView(generic.BulkDeleteView): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group')