Prechádzať zdrojové kódy

Closes #5892: Introduce SiteGroup model (#5937)

* Initial work on #5892

* Add site group selection to object edit forms

* Add documentation for site groups

* Changelog for #5892

* Finish application of site groups to config context
Jeremy Stretch 5 rokov pred
rodič
commit
79b19821f6
44 zmenil súbory, kde vykonal 1388 pridanie a 232 odobranie
  1. 1 0
      docs/core-functionality/sites-and-racks.md
  2. 3 0
      docs/models/dcim/sitegroup.md
  3. 10 0
      docs/release-notes/version-2.11.md
  4. 38 12
      netbox/circuits/filters.py
  5. 12 4
      netbox/circuits/forms.py
  6. 43 15
      netbox/circuits/tests/test_filters.py
  7. 11 0
      netbox/dcim/api/nested_serializers.py
  8. 19 11
      netbox/dcim/api/serializers.py
  9. 1 0
      netbox/dcim/api/urls.py
  10. 17 7
      netbox/dcim/api/views.py
  11. 122 8
      netbox/dcim/filters.py
  12. 214 38
      netbox/dcim/forms.py
  13. 39 0
      netbox/dcim/migrations/0130_sitegroup.py
  14. 1 0
      netbox/dcim/models/__init__.py
  15. 68 4
      netbox/dcim/models/sites.py
  16. 25 3
      netbox/dcim/tables/sites.py
  17. 14 11
      netbox/dcim/tests/test_api.py
  18. 297 55
      netbox/dcim/tests/test_filters.py
  19. 12 3
      netbox/dcim/tests/test_views.py
  20. 10 5
      netbox/dcim/urls.py
  21. 47 1
      netbox/dcim/views.py
  22. 11 4
      netbox/extras/api/serializers.py
  23. 1 1
      netbox/extras/api/views.py
  24. 12 1
      netbox/extras/filters.py
  25. 14 5
      netbox/extras/forms.py
  26. 17 0
      netbox/extras/migrations/0056_sitegroup.py
  27. 5 0
      netbox/extras/models/models.py
  28. 17 2
      netbox/extras/tests/test_filters.py
  29. 40 1
      netbox/ipam/filters.py
  30. 72 12
      netbox/ipam/forms.py
  31. 55 13
      netbox/ipam/tests/test_filters.py
  32. 1 0
      netbox/templates/circuits/circuittermination_edit.html
  33. 9 0
      netbox/templates/dcim/cable_connect.html
  34. 1 0
      netbox/templates/dcim/device_edit.html
  35. 1 0
      netbox/templates/dcim/rack_edit.html
  36. 13 0
      netbox/templates/dcim/site.html
  37. 1 0
      netbox/templates/dcim/virtualchassis_add.html
  38. 14 0
      netbox/templates/extras/configcontext.html
  39. 1 0
      netbox/templates/extras/configcontext_edit.html
  40. 9 0
      netbox/templates/inc/nav_menu.html
  41. 1 0
      netbox/templates/ipam/ipaddress_edit.html
  42. 27 1
      netbox/virtualization/filters.py
  43. 25 6
      netbox/virtualization/forms.py
  44. 37 9
      netbox/virtualization/tests/test_filters.py

+ 1 - 0
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!}
 

+ 3 - 0
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.

+ 10 - 0
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

+ 38 - 12
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:

+ 12 - 4
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",

+ 43 - 15
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]}

+ 11 - 0
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')
 

+ 19 - 11
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',
         ]
 
 

+ 1 - 0
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

+ 17 - 7
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
 #

+ 122 - 8
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(),

+ 214 - 38
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,

+ 39 - 0
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'),
+        ),
+    ]

+ 1 - 0
netbox/dcim/models/__init__.py

@@ -41,5 +41,6 @@ __all__ = (
     'RearPortTemplate',
     'Region',
     'Site',
+    'SiteGroup',
     'VirtualChassis',
 )

+ 68 - 4
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,

+ 25 - 3
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')

+ 14 - 11
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,
             },
         ]

+ 297 - 55
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]}

+ 12 - 3
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'),

+ 10 - 5
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/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
     path('regions/<int:pk>/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/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
+    path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
+    path('site-groups/<int:pk>/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'),

+ 47 - 1
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:

+ 11 - 4
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',
         ]
 
 

+ 1 - 1
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

+ 12 - 1
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(),

+ 14 - 5
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,

+ 17 - 0
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'),
+        ),
+    ]

+ 5 - 0
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='+',

+ 17 - 2
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]}

+ 40 - 1
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)',

+ 72 - 12
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,

+ 55 - 13
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]}

+ 1 - 0
netbox/templates/circuits/circuittermination_edit.html

@@ -27,6 +27,7 @@
               </div>
           </div>
           {% render_field form.region %}
+          {% render_field form.site_group %}
           {% render_field form.site %}
           {% render_field form.mark_connected %}
       </div>

+ 9 - 0
netbox/templates/dcim/cable_connect.html

@@ -38,6 +38,12 @@
                                     <p class="form-control-static">{{ termination_a.device.site.region }}</p>
                                 </div>
                             </div>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Site Group</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.device.site.group }}</p>
+                                </div>
+                            </div>
                             <div class="form-group">
                                 <label class="col-md-3 control-label required">Site</label>
                                 <div class="col-md-9">
@@ -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 %}

+ 1 - 0
netbox/templates/dcim/device_edit.html

@@ -23,6 +23,7 @@
         <div class="panel-heading"><strong>Location</strong></div>
         <div class="panel-body">
             {% render_field form.region %}
+            {% render_field form.site_group %}
             {% render_field form.site %}
             {% render_field form.location %}
             {% render_field form.rack %}

+ 1 - 0
netbox/templates/dcim/rack_edit.html

@@ -6,6 +6,7 @@
         <div class="panel-heading"><strong>Rack</strong></div>
         <div class="panel-body">
             {% render_field form.region %}
+            {% render_field form.site_group %}
             {% render_field form.site %}
             {% render_field form.location %}
             {% render_field form.name %}

+ 13 - 0
netbox/templates/dcim/site.html

@@ -41,6 +41,19 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Group</td>
+                    <td>
+                        {% if object.group %}
+                            {% for group in object.group.get_ancestors %}
+                                <a href="{{ group.get_absolute_url }}">{{ group }}</a> /
+                            {% endfor %}
+                            <a href="{{ object.group.get_absolute_url }}">{{ object.group }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                     <td>Tenant</td>
                     <td>

+ 1 - 0
netbox/templates/dcim/virtualchassis_add.html

@@ -14,6 +14,7 @@
         <div class="panel-heading"><strong>Member Devices</strong></div>
         <div class="panel-body">
             {% render_field form.region %}
+            {% render_field form.site_group %}
             {% render_field form.site %}
             {% render_field form.rack %}
             {% render_field form.members %}

+ 14 - 0
netbox/templates/extras/configcontext.html

@@ -66,6 +66,20 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <td>Site Groups</td>
+                        <td>
+                            {% if object.site_groups.all %}
+                                <ul>
+                                    {% for sitegroup in object.site_groups.all %}
+                                        <li><a href="{{ sitegroup.get_absolute_url }}">{{ sitegroup }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
                     <tr>
                         <td>Sites</td>
                         <td>

+ 1 - 0
netbox/templates/extras/configcontext_edit.html

@@ -15,6 +15,7 @@
         <div class="panel-heading"><strong>Assignment</strong></div>
         <div class="panel-body">
             {% render_field form.regions %}
+            {% render_field form.site_groups %}
             {% render_field form.sites %}
             {% render_field form.roles %}
             {% render_field form.platforms %}

+ 9 - 0
netbox/templates/inc/nav_menu.html

@@ -38,6 +38,15 @@
                             {% endif %}
                             <a href="{% url 'dcim:region_list' %}">Regions</a>
                         </li>
+                        <li{% if not perms.dcim.view_sitegroup %} class="disabled"{% endif %}>
+                            {% if perms.dcim.add_sitegroup %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:sitegroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="mdi mdi-plus-thick"></i></a>
+                                    <a href="{% url 'dcim:sitegroup_import' %}" class="btn btn-xs btn-info" title="Import"><i class="mdi mdi-database-import-outline"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'dcim:sitegroup_list' %}">Site Groups</a>
+                        </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Racks</li>
                         <li{% if not perms.dcim.view_rack %} class="disabled"{% endif %}>

+ 1 - 0
netbox/templates/ipam/ipaddress_edit.html

@@ -64,6 +64,7 @@
             <div class="tab-content">
                 <div class="tab-pane active" id="by_device">
                     {% 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 %}

+ 27 - 1
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(),

+ 25 - 6
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(

+ 37 - 9
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]}