Merge branch 'feature' into 5284-vlangroup-scope

This commit is contained in:
Jeremy Stretch 2021-03-15 20:48:55 -04:00
commit 10778f8479
38 changed files with 760 additions and 170 deletions

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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'),

View File

@ -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')

View File

@ -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( class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
TaggedObjectSerializer,
CableTerminationSerializer,
ConnectedEndpointSerializer,
CustomFieldModelSerializer
):
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(

View File

@ -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
# #

View File

@ -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):
""" """

View File

@ -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})")

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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

View File

@ -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)

View File

@ -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
# #

View File

@ -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)

View File

@ -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(),

View File

@ -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

View File

@ -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'),

View File

@ -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')

View File

@ -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 # Nested serializers
#
class NestedGroupModelSerializer(CustomFieldModelSerializer):
_depth = serializers.IntegerField(source='level', read_only=True)
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()

View File

@ -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 netbox.api import ContentTypeField
from extras.api.serializers import TaggedObjectSerializer 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)

View File

@ -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
# #

View File

@ -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(

View File

@ -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'),

View File

@ -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')

View File

@ -1,7 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from netbox.api.serializers import CustomFieldModelSerializer, NestedGroupModelSerializer from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
from extras.api.serializers import TaggedObjectSerializer
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)

View File

@ -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
# #

View File

@ -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

View File

@ -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'),

View File

@ -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(),

View File

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

View File

@ -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)

View File

@ -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
# #

View File

@ -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

View File

@ -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'),

View File

@ -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')