diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md index b57826868..1b5ee3ad1 100644 --- a/docs/core-functionality/sites-and-racks.md +++ b/docs/core-functionality/sites-and-racks.md @@ -1,6 +1,7 @@ # Sites and Racks {!docs/models/dcim/region.md!} +{!docs/models/dcim/sitegroup.md!} {!docs/models/dcim/site.md!} {!docs/models/dcim/location.md!} diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md new file mode 100644 index 000000000..3c1ed11bd --- /dev/null +++ b/docs/models/dcim/sitegroup.md @@ -0,0 +1,3 @@ +# Site Groups + +Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups. diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 03b3205a0..f2272c7a4 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -26,6 +26,10 @@ When exporting a list of objects in NetBox, users now have the option of selecti The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12. +#### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892)) + +This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. + #### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. @@ -68,6 +72,12 @@ The ObjectChange model (which is used to record the creation, modification, and * Renamed `rack_group` field to `location` * dcim.Rack * Renamed `group` field to `location` +* dcim.Site + * Added the `group` foreign key field to SiteGroup +* dcim.SiteGroup + * Added the `/api/dcim/site-groups/` endpoint +* extras.ConfigContext + * Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups * extras.CustomField * Added new custom field type: `multi-select` * extras.ObjectChange 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/forms.py b/netbox/circuits/forms.py index adbf78fdc..cffbf14ae 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import Region, Site +from dcim.models import Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) @@ -320,18 +320,26 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', + 'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", 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..102c3114b 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) @@ -369,10 +429,18 @@ class LocationForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) parent = DynamicModelChoiceField( @@ -387,7 +455,7 @@ class LocationForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Location fields = ( - 'region', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', ) @@ -474,10 +542,18 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) location = DynamicModelChoiceField( @@ -500,9 +576,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Rack fields = [ - 'region', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', + 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -586,11 +662,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) location = DynamicModelChoiceField( @@ -751,11 +835,19 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) location = DynamicModelChoiceField( @@ -791,7 +883,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = RackReservation fields = [ - 'region', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', + 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', + 'description', 'tags', ] fieldsets = ( ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), @@ -1778,10 +1871,19 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), + required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) location = DynamicModelChoiceField( @@ -1867,9 +1969,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', - 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', - 'comments', 'tags', 'local_context_data' + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', + 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', + 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -3678,12 +3780,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): label='Region', required=False ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, query_params={ - 'region_id': '$termination_b_region' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) termination_b_rack = DynamicModelChoiceField( @@ -3817,12 +3925,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) label='Region', required=False ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, query_params={ - 'region_id': '$termination_b_region' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) termination_b_circuit = DynamicModelChoiceField( @@ -3866,12 +3980,18 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): label='Region', required=False ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, query_params={ - 'region_id': '$termination_b_region' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) termination_b_location = DynamicModelChoiceField( @@ -4245,11 +4365,19 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( @@ -4283,7 +4411,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', ] def save(self, *args, **kwargs): @@ -4387,11 +4515,19 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( @@ -4451,7 +4587,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 +4597,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, @@ -4484,10 +4625,19 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), + required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) location = DynamicModelChoiceField( @@ -4505,7 +4655,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerPanel fields = [ - 'region', 'site', 'location', 'name', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'tags', ] fieldsets = ( ('Power Panel', ('region', 'site', 'location', 'name', 'tags')), @@ -4550,11 +4700,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) location = DynamicModelChoiceField( @@ -4580,6 +4738,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, @@ -4612,6 +4775,13 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'sites__powerpanel': '$power_panel' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -4619,7 +4789,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'powerpanel': '$power_panel' }, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) power_panel = DynamicModelChoiceField( @@ -4645,8 +4816,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerFeed fields = [ - 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', + 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', + 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] fieldsets = ( ('Power Panel', ('region', 'site', 'power_panel')), @@ -4803,6 +4974,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..1a68ba932 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 # @@ -2181,6 +2225,8 @@ class CableCreateView(generic.ObjectEditView): termination_a_site = getattr(obj.termination_a.parent_object, 'site', None) if termination_a_site and 'termination_b_region' not in initial_data: initial_data['termination_b_region'] = termination_a_site.region + if termination_a_site and 'termination_b_site_group' not in initial_data: + initial_data['termination_b_site_group'] = termination_a_site.group if 'termination_b_site' not in initial_data: initial_data['termination_b_site'] = termination_a_site if 'termination_b_rack' not in initial_data: diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 1d956bb91..dc903a0ab 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,9 +5,9 @@ from rest_framework import serializers from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, - NestedRegionSerializer, NestedSiteSerializer, + NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.choices import * from extras.models import ( ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, @@ -166,6 +166,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + site_groups = SerializedPKRelatedField( + queryset=SiteGroup.objects.all(), + serializer=NestedSiteGroupSerializer, + required=False, + many=True + ) sites = SerializedPKRelatedField( queryset=Site.objects.all(), serializer=NestedSiteSerializer, @@ -218,8 +224,9 @@ class ConfigContextSerializer(ValidatedModelSerializer): class Meta: model = ConfigContext fields = [ - 'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', - 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', + 'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', + 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', + 'last_updated', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1067ac0d3..b46c2367b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -124,7 +124,7 @@ class ImageAttachmentViewSet(ModelViewSet): class ConfigContextViewSet(ModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filters.ConfigContextFilterSet 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..ab25833ad 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, ) @@ -369,12 +369,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, null_option='None', query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) vlan_group = DynamicModelChoiceField( @@ -416,7 +424,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ] fieldsets = ( ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')), - ('Site/VLAN Assignment', ('region', 'site', 'vlan_group', 'vlan')), + ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), ('Tenancy', ('tenant_group', 'tenant')), ) widgets = { @@ -497,11 +505,16 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Region.objects.all(), required=False ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) vrf = DynamicModelChoiceField( @@ -547,8 +560,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 +612,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, @@ -673,12 +691,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel 'sites': '$nat_site' } ) + nat_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label='Site group', + initial_params={ + 'sites': '$nat_site' + } + ) nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', query_params={ - 'region_id': '$nat_region' + 'region_id': '$nat_region', + 'group_id': '$nat_site_group', } ) nat_rack = DynamicModelChoiceField( @@ -1089,11 +1116,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) slug = SlugField() @@ -1125,6 +1160,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, @@ -1148,12 +1188,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, null_option='None', query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) group = DynamicModelChoiceField( @@ -1179,7 +1227,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ] fieldsets = ( ('VLAN', ('vid', 'name', 'status', 'role', 'description', 'tags')), - ('Assignment', ('region', 'site', 'group')), + ('Assignment', ('region', 'site_group', 'site', 'group')), ('Tenancy', ('tenant_group', 'tenant')), ) help_texts = { @@ -1252,11 +1300,16 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Region.objects.all(), required=False ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) group = DynamicModelChoiceField( @@ -1292,7 +1345,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 +1357,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/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 01aa96c14..4e737d16d 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -27,6 +27,7 @@ {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.mark_connected %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index 78b2e5aa7..3215bc8e9 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -38,6 +38,12 @@

{{ termination_a.device.site.region }}

+
+ +
+

{{ termination_a.device.site.group }}

+
+
@@ -120,6 +126,9 @@ {% if 'termination_b_region' in form.fields %} {% render_field form.termination_b_region %} {% endif %} + {% if 'termination_b_site_group' in form.fields %} + {% render_field form.termination_b_site_group %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index b84b7e5a2..0859a163b 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -23,6 +23,7 @@
Location
{% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.location %} {% render_field form.rack %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 63a9fc183..af94a7197 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -6,6 +6,7 @@
Rack
{% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.location %} {% render_field form.name %} 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/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 2a68cb5e4..6dc9fc59f 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -14,6 +14,7 @@
Member Devices
{% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.rack %} {% render_field form.members %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 015ccb35d..7ece11b2e 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -66,6 +66,20 @@ {% endif %} + + Site Groups + + {% if object.site_groups.all %} +
    + {% for sitegroup in object.site_groups.all %} +
  • {{ sitegroup }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} + + Sites diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 08e92d258..d0fe66a3a 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -15,6 +15,7 @@
Assignment
{% render_field form.regions %} + {% render_field form.site_groups %} {% render_field form.sites %} {% render_field form.roles %} {% render_field form.platforms %} 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/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 33e58ce09..01c7b25cd 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -64,6 +64,7 @@
    {% render_field form.nat_region %} + {% render_field form.nat_site_group %} {% render_field form.nat_site %} {% render_field form.nat_rack %} {% render_field form.nat_device %} 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/forms.py b/netbox/virtualization/forms.py index 65c2e45d4..c2d0aee38 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -87,11 +87,19 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) comments = CommentField() @@ -103,10 +111,10 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -162,11 +170,16 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Region.objects.all(), required=False, ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) comments = CommentField( @@ -223,11 +236,17 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): required=False, null_option='None' ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + null_option='None' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( 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]}