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
* [#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

View File

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

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):
slug = SlugField()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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