diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fa563881c..03da662e7 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet -from dcim.models import Region, Site +from dcim.models import Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -37,6 +37,19 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='circuits__terminations__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='circuits__terminations__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', queryset=Site.objects.all(), @@ -102,17 +115,6 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe choices=CircuitStatusChoices, null_value=None ) - site_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site', - queryset=Site.objects.all(), - label='Site (ID)', - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label='Site (slug)', - ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='terminations__site__region', @@ -126,6 +128,30 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='terminations__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='terminations__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) tag = TagFilter() class Meta: diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 9477bfbac..b9e1eac45 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -3,7 +3,7 @@ from django.test import TestCase from circuits.choices import * from circuits.filters import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.models import Cable, Region, Site +from dcim.models import Cable, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup @@ -27,13 +27,20 @@ class ProviderTestCase(TestCase): Region(name='Test Region 1', slug='test-region-1'), Region(name='Test Region 2', slug='test-region-2'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), ) Site.objects.bulk_create(sites) @@ -74,13 +81,6 @@ class ProviderTestCase(TestCase): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_site(self): - sites = Site.objects.all()[:2] - params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -88,6 +88,20 @@ class ProviderTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class CircuitTypeTestCase(TestCase): queryset = CircuitType.objects.all() @@ -127,14 +141,21 @@ class CircuitTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -223,6 +244,13 @@ class CircuitTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 92c151374..3dd5d1230 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -35,6 +35,7 @@ __all__ = [ 'NestedRearPortTemplateSerializer', 'NestedRegionSerializer', 'NestedSiteSerializer', + 'NestedSiteGroupSerializer', 'NestedVirtualChassisSerializer', ] @@ -53,6 +54,16 @@ class NestedRegionSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth'] +class NestedSiteGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + site_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.SiteGroup + fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth'] + + class NestedSiteSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index faeaaa11a..642858d28 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,13 +6,7 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.choices import * from dcim.constants import * -from dcim.models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.models import * from netbox.api.serializers import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -94,10 +88,24 @@ class RegionSerializer(NestedGroupModelSerializer): ] +class SiteGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + parent = NestedRegionSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = SiteGroup + fields = [ + 'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'site_count', '_depth', + ] + + class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) + group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) circuit_count = serializers.IntegerField(read_only=True) @@ -110,10 +118,10 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', - 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'id', 'url', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fbac1c67f..43f956cb2 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -7,6 +7,7 @@ router.APIRootView = views.DCIMRootView # Sites router.register('regions', views.RegionViewSet) +router.register('site-groups', views.SiteGroupViewSet) router.register('sites', views.SiteViewSet) # Racks diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e869749db..6aa4fd484 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -16,13 +16,7 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit from dcim import filters -from dcim.models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet from ipam.models import Prefix, VLAN from netbox.api.views import ModelViewSet @@ -111,6 +105,22 @@ class RegionViewSet(CustomFieldModelViewSet): filterset_class = filters.RegionFilterSet +# +# Site groups +# + +class SiteGroupViewSet(CustomFieldModelViewSet): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + serializer_class = serializers.SiteGroupSerializer + filterset_class = filters.SiteGroupFilterSet + + # # Sites # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 6eef1671e..8d19dc2f0 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -12,13 +12,7 @@ from utilities.filters import ( from virtualization.models import Cluster from .choices import * from .constants import * -from .models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from .models import * __all__ = ( @@ -58,6 +52,7 @@ __all__ = ( 'RearPortTemplateFilterSet', 'RegionFilterSet', 'SiteFilterSet', + 'SiteGroupFilterSet', 'VirtualChassisFilterSet', ) @@ -79,6 +74,23 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'description'] +class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + label='Parent site group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=SiteGroup.objects.all(), + to_field_name='slug', + label='Parent site group (slug)', + ) + + class Meta: + model = SiteGroup + fields = ['id', 'name', 'slug', 'description'] + + class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', @@ -96,11 +108,22 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='group', + lookup_expr='in', + label='Group (ID)', + ) + group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + lookup_expr='in', + to_field_name='slug', + label='Group (slug)', + ) tag = TagFilter() class Meta: @@ -145,6 +168,19 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -196,6 +232,19 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -565,6 +614,19 @@ class DeviceFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -721,6 +783,19 @@ class DeviceComponentFilterSet(CustomFieldModelFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', queryset=Site.objects.all(), @@ -1043,6 +1118,19 @@ class VirtualChassisFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='master__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='master__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', queryset=Site.objects.all(), @@ -1231,6 +1319,19 @@ class PowerPanelFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -1286,6 +1387,19 @@ class PowerFeedFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='power_panel__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='power_panel__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', queryset=Site.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index fa440aadf..567e68b42 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -31,12 +31,7 @@ from utilities.forms import ( from virtualization.models import Cluster, ClusterGroup from .choices import * from .constants import * -from .models import ( - Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, - Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, - Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from .models import * DEVICE_BY_PK_RE = r'{\d+\}' @@ -61,7 +56,7 @@ def get_device_by_name_or_pk(name): class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): field_order = [ - 'q', 'region_id', 'site_id' + 'q', 'region_id', 'site_group_id', 'site_id' ] q = forms.CharField( required=False, @@ -72,6 +67,11 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -209,6 +209,45 @@ class RegionFilterForm(BootstrapMixin, forms.Form): ) +# +# Site groups +# + +class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = SiteGroup + fields = ( + 'parent', 'name', 'slug', 'description', + ) + + +class SiteGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site group' + ) + + class Meta: + model = SiteGroup + fields = SiteGroup.csv_headers + + +class SiteGroupFilterForm(BootstrapMixin, forms.Form): + model = Site + q = forms.CharField( + required=False, + label=_('Search') + ) + + # # Sites # @@ -218,6 +257,10 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Region.objects.all(), required=False ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) slug = SlugField() comments = CommentField() tags = DynamicModelMultipleChoiceField( @@ -228,12 +271,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Site fields = [ - 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', ] fieldsets = ( - ('Site', ('name', 'slug', 'status', 'region', 'facility', 'asn', 'time_zone', 'description', 'tags')), + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', + )), ('Tenancy', ('tenant_group', 'tenant')), ('Contact Info', ( 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', @@ -279,6 +324,12 @@ class SiteCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Assigned region' ) + group = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -311,6 +362,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Region.objects.all(), required=False ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -333,7 +388,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class Meta: nullable_fields = [ - 'region', 'tenant', 'asn', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', ] @@ -354,6 +409,11 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label=_('Region') ) + group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Group') + ) tag = TagFilterField(model) @@ -4451,7 +4511,7 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VirtualChassis - field_order = ['q', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id'] + field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, label=_('Search') @@ -4461,6 +4521,11 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -4580,6 +4645,11 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -4803,6 +4873,11 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/dcim/migrations/0130_sitegroup.py b/netbox/dcim/migrations/0130_sitegroup.py new file mode 100644 index 000000000..3b3bdcf10 --- /dev/null +++ b/netbox/dcim/migrations/0130_sitegroup.py @@ -0,0 +1,39 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0129_interface_parent'), + ] + + operations = [ + migrations.CreateModel( + name='SiteGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.sitegroup')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.sitegroup'), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index a533636fc..ee19d553d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -41,5 +41,6 @@ __all__ = ( 'RearPortTemplate', 'Region', 'Site', + 'SiteGroup', 'VirtualChassis', ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 5aea4461f..1d26cd44a 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -17,6 +17,7 @@ from utilities.querysets import RestrictedQuerySet __all__ = ( 'Region', 'Site', + 'SiteGroup', ) @@ -27,7 +28,9 @@ __all__ = ( @extras_features('custom_fields', 'export_templates', 'webhooks') class Region(NestedGroupModel): """ - Sites can be grouped within geographic Regions. + A region represents a geographic collection of sites. For example, you might create regions representing countries, + states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are + also considered to be members of its parent and ancestor region(s). """ parent = TreeForeignKey( to='self', @@ -70,6 +73,58 @@ class Region(NestedGroupModel): ).count() +# +# Site groups +# + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class SiteGroup(NestedGroupModel): + """ + A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and + within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be + nested recursively to form a hierarchy. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + csv_headers = ['name', 'slug', 'parent', 'description'] + + def get_absolute_url(self): + return "{}?group={}".format(reverse('dcim:site_list'), self.slug) + + def to_csv(self): + return ( + self.name, + self.slug, + self.parent.name if self.parent else None, + self.description, + ) + + def get_site_count(self): + return Site.objects.filter( + Q(group=self) | + Q(group__in=self.get_descendants()) + ).count() + + # # Sites # @@ -105,6 +160,13 @@ class Site(PrimaryModel): blank=True, null=True ) + group = models.ForeignKey( + to='dcim.SiteGroup', + on_delete=models.SET_NULL, + related_name='sites', + blank=True, + null=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -175,11 +237,12 @@ class Site(PrimaryModel): objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', ] clone_fields = [ - 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', + 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] @@ -198,6 +261,7 @@ class Site(PrimaryModel): self.slug, self.get_status_display(), self.region.name if self.region else None, + self.group.name if self.group else None, self.tenant.name if self.tenant else None, self.facility, self.asn, diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 9ef6d873b..e22037255 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,12 +1,13 @@ import django_tables2 as tables -from dcim.models import Region, Site +from dcim.models import Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn __all__ = ( 'RegionTable', 'SiteTable', + 'SiteGroupTable', ) @@ -28,6 +29,24 @@ class RegionTable(BaseTable): default_columns = ('pk', 'name', 'site_count', 'description', 'actions') +# +# Site groups +# + +class SiteGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn() + site_count = tables.Column( + verbose_name='Sites' + ) + actions = ButtonsColumn(SiteGroup) + + class Meta(BaseTable.Meta): + model = SiteGroup + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + + # # Sites # @@ -41,6 +60,9 @@ class SiteTable(BaseTable): region = tables.Column( linkify=True ) + group = tables.Column( + linkify=True + ) tenant = TenantColumn() tags = TagColumn( url_name='dcim:site_list' @@ -49,8 +71,8 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( - 'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description', + 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 278883af2..5a37e3158 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -4,12 +4,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel, - Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from dcim.models import * from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType @@ -102,14 +97,19 @@ class SiteTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): regions = ( - Region.objects.create(name='Test Region 1', slug='test-region-1'), - Region.objects.create(name='Test Region 2', slug='test-region-2'), + Region.objects.create(name='Region 1', slug='region-1'), + Region.objects.create(name='Region 2', slug='region-2'), + ) + + groups = ( + SiteGroup.objects.create(name='Site Group 1', slug='site-group-1'), + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'), ) sites = ( - Site(region=regions[0], name='Site 1', slug='site-1'), - Site(region=regions[0], name='Site 2', slug='site-2'), - Site(region=regions[0], name='Site 3', slug='site-3'), + Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'), + Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'), + Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) @@ -118,18 +118,21 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'name': 'Site 4', 'slug': 'site-4', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, { 'name': 'Site 5', 'slug': 'site-5', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, { 'name': 'Site 6', 'slug': 'site-6', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, ] diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index f491c2f75..67b9252c5 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -3,13 +3,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filters import * -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerPortTemplate, PowerOutlet, - PowerOutletTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.models import * from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterType @@ -80,6 +74,14 @@ class SiteTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -96,9 +98,9 @@ class SiteTestCase(TestCase): Tenant.objects.bulk_create(tenants) sites = ( - Site(name='Site 1', slug='site-1', region=regions[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), - Site(name='Site 2', slug='site-2', region=regions[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), - Site(name='Site 3', slug='site-3', region=regions[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), ) Site.objects.bulk_create(sites) @@ -153,6 +155,13 @@ class SiteTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + groups = SiteGroup.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} @@ -183,10 +192,18 @@ class LocationTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -229,6 +246,13 @@ class LocationTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -290,10 +314,18 @@ class RackTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -388,6 +420,13 @@ class RackTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1161,10 +1200,18 @@ class DeviceTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -1324,6 +1371,13 @@ class DeviceTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1463,10 +1517,19 @@ class ConsolePortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1524,6 +1587,13 @@ class ConsolePortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1559,10 +1629,19 @@ class ConsoleServerPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1620,6 +1699,13 @@ class ConsoleServerPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1655,10 +1741,19 @@ class PowerPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1724,6 +1819,13 @@ class PowerPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1759,10 +1861,19 @@ class PowerOutletTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1825,6 +1936,13 @@ class PowerOutletTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1860,10 +1978,19 @@ class InterfaceTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1966,6 +2093,13 @@ class InterfaceTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2015,10 +2149,19 @@ class FrontPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2082,6 +2225,13 @@ class FrontPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2117,10 +2267,19 @@ class RearPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2178,6 +2337,13 @@ class RearPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2213,10 +2379,19 @@ class DeviceBayTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + 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 group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2256,6 +2431,13 @@ class DeviceBayTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2296,10 +2478,18 @@ class InventoryItemTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2356,6 +2546,13 @@ class InventoryItemTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2409,10 +2606,18 @@ class VirtualChassisTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2463,6 +2668,13 @@ class VirtualChassisTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2610,10 +2822,18 @@ class PowerPanelTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2647,6 +2867,13 @@ class PowerPanelTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2675,10 +2902,18 @@ class PowerFeedTestCase(TestCase): for region in regions: region.save() + groups = ( + 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 group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2759,6 +2994,13 @@ class PowerFeedTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 161e3c5a6..7dc57a3ea 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -71,10 +71,17 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + ) + for group in groups: + group.save() + Site.objects.bulk_create([ - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[0]), - Site(name='Site 3', slug='site-3', region=regions[0]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), + Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), ]) tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') @@ -84,6 +91,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, + 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', 'asn': 65001, @@ -110,6 +118,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, + 'group': groups[1].pk, 'tenant': None, 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 47d0572f3..89d739743 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,11 +3,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceEditView from . import views -from .models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, Location, - RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, -) +from .models import * app_name = 'dcim' urlpatterns = [ @@ -21,6 +17,15 @@ urlpatterns = [ path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + # Site groups + 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/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), + path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), + path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), + path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), + # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), path('sites/add/', views.SiteEditView.as_view(), name='site_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f39391a8b..8d20ea5ef 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -31,7 +31,7 @@ from .models import ( DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, + SiteGroup, VirtualChassis, ) @@ -138,6 +138,50 @@ class RegionBulkDeleteView(generic.BulkDeleteView): table = tables.RegionTable +# +# Site groups +# + +class SiteGroupListView(generic.ObjectListView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filters.SiteGroupFilterSet + filterset_form = forms.SiteGroupFilterForm + table = tables.SiteGroupTable + + +class SiteGroupEditView(generic.ObjectEditView): + queryset = SiteGroup.objects.all() + model_form = forms.SiteGroupForm + + +class SiteGroupDeleteView(generic.ObjectDeleteView): + queryset = SiteGroup.objects.all() + + +class SiteGroupBulkImportView(generic.BulkImportView): + queryset = SiteGroup.objects.all() + model_form = forms.SiteGroupCSVForm + table = tables.SiteGroupTable + + +class SiteGroupBulkDeleteView(generic.BulkDeleteView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filters.SiteGroupFilterSet + table = tables.SiteGroupTable + + # # Sites # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index e3c313735..4a7b36d49 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.forms import DateField, IntegerField, NullBooleanField -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet, ContentTypeFilter from virtualization.models import Cluster, ClusterGroup @@ -129,6 +129,17 @@ class ConfigContextFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group = django_filters.ModelMultipleChoiceFilter( + field_name='site_groups__slug', + queryset=SiteGroup.objects.all(), + to_field_name='slug', + label='Site group (slug)', + ) + site_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='site_groups', + queryset=SiteGroup.objects.all(), + label='Site group', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', queryset=Site.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index ebb6ac5d4..b735588dc 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, @@ -210,6 +210,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Region.objects.all(), required=False ) + site_groups = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False @@ -249,8 +253,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', - 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'platforms', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) @@ -280,8 +284,8 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): class ConfigContextFilterForm(BootstrapMixin, forms.Form): field_order = [ - 'q', 'region_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id', 'tenant_group_id', - 'tenant_id', + 'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id', + 'tenant_group_id', 'tenant_id', ] q = forms.CharField( required=False, @@ -292,6 +296,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label=_('Regions') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site groups') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/extras/migrations/0056_sitegroup.py b/netbox/extras/migrations/0056_sitegroup.py new file mode 100644 index 000000000..b81cdb8a1 --- /dev/null +++ b/netbox/extras/migrations/0056_sitegroup.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0130_sitegroup'), + ('extras', '0055_objectchange_data'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='site_groups', + field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index bf0dfc873..53ac9a590 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -386,6 +386,11 @@ class ConfigContext(ChangeLoggingMixin, BigIDModel): related_name='+', blank=True ) + site_groups = models.ManyToManyField( + to='dcim.SiteGroup', + related_name='+', + blank=True + ) sites = models.ManyToManyField( to='dcim.Site', related_name='+', diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 19278ba75..8f53aa521 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Rack, Region, Site +from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.choices import ObjectChangeActionChoices from extras.filters import * from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, Tag @@ -132,10 +132,17 @@ class ConfigContextTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( Site(name='Test Site 1', slug='test-site-1'), Site(name='Test Site 2', slug='test-site-2'), @@ -195,6 +202,7 @@ class ConfigContextTestCase(TestCase): data='{"foo": 123}' ) c.regions.set([regions[i]]) + c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) @@ -224,6 +232,13 @@ class ConfigContextTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 176d77a88..5ca487065 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.db.models import Q from netaddr.core import AddrFormatError -from dcim.models import Device, Interface, Region, Site +from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -254,6 +254,19 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -535,6 +548,19 @@ class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -569,6 +595,19 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 664081010..85a06c352 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import Device, Interface, Rack, Region, Site +from dcim.models import Device, Interface, Rack, Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -547,8 +547,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Prefix field_order = [ - 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id', 'site_id', - 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', + 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id', + 'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', ] mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() @@ -599,6 +599,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -1125,6 +1130,11 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -1292,7 +1302,9 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VLAN - field_order = ['q', 'region_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] + field_order = [ + 'q', 'region_id', 'site_group_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + ] q = forms.CharField( required=False, label='Search' @@ -1302,6 +1314,11 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 2fead3d93..f26385ae6 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF @@ -343,14 +343,21 @@ class PrefixTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -468,6 +475,13 @@ class PrefixTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -701,14 +715,21 @@ class VLANGroupTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -743,6 +764,13 @@ class VLANGroupTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -763,14 +791,21 @@ class VLANTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -832,6 +867,13 @@ class VLANTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1618ed69a..093c9496c 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -41,6 +41,19 @@ {% endif %} + + Group + + {% if object.group %} + {% for group in object.group.get_ancestors %} + {{ group }} / + {% endfor %} + {{ object.group }} + {% else %} + None + {% endif %} + + Tenant diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index c0b4d4db4..621251ffb 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -38,6 +38,15 @@ {% endif %} Regions + + {% if perms.dcim.add_sitegroup %} +
+ + +
+ {% endif %} + Site Groups +
  • diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index af53afad5..ea668c737 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -52,6 +52,19 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSe to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -151,6 +164,19 @@ class VirtualMachineFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='cluster__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='cluster__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__site', queryset=Site.objects.all(), diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index e27c063e1..e822d8763 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from virtualization.choices import * @@ -96,14 +96,21 @@ class ClusterTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -144,6 +151,13 @@ class ClusterTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -206,14 +220,21 @@ class VirtualMachineTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + 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 site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), + Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), + Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -329,6 +350,13 @@ class VirtualMachineTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]}