Skip to content

Commit 79b1982

Browse files
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
1 parent 1c66733 commit 79b1982

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1388
-232
lines changed

docs/core-functionality/sites-and-racks.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Sites and Racks
22

33
{!docs/models/dcim/region.md!}
4+
{!docs/models/dcim/sitegroup.md!}
45
{!docs/models/dcim/site.md!}
56
{!docs/models/dcim/location.md!}
67

docs/models/dcim/sitegroup.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Site Groups
2+
3+
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.

docs/release-notes/version-2.11.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ When exporting a list of objects in NetBox, users now have the option of selecti
2626

2727
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.
2828

29+
#### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892))
30+
31+
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.
32+
2933
#### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913))
3034

3135
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
6872
* Renamed `rack_group` field to `location`
6973
* dcim.Rack
7074
* Renamed `group` field to `location`
75+
* dcim.Site
76+
* Added the `group` foreign key field to SiteGroup
77+
* dcim.SiteGroup
78+
* Added the `/api/dcim/site-groups/` endpoint
79+
* extras.ConfigContext
80+
* Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups
7181
* extras.CustomField
7282
* Added new custom field type: `multi-select`
7383
* extras.ObjectChange

netbox/circuits/filters.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.db.models import Q
33

44
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
5-
from dcim.models import Region, Site
5+
from dcim.models import Region, Site, SiteGroup
66
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
77
from tenancy.filters import TenancyFilterSet
88
from utilities.filters import (
@@ -37,6 +37,19 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
3737
to_field_name='slug',
3838
label='Region (slug)',
3939
)
40+
site_group_id = TreeNodeMultipleChoiceFilter(
41+
queryset=SiteGroup.objects.all(),
42+
field_name='circuits__terminations__site__group',
43+
lookup_expr='in',
44+
label='Site group (ID)',
45+
)
46+
site_group = TreeNodeMultipleChoiceFilter(
47+
queryset=SiteGroup.objects.all(),
48+
field_name='circuits__terminations__site__group',
49+
lookup_expr='in',
50+
to_field_name='slug',
51+
label='Site group (slug)',
52+
)
4053
site_id = django_filters.ModelMultipleChoiceFilter(
4154
field_name='circuits__terminations__site',
4255
queryset=Site.objects.all(),
@@ -102,17 +115,6 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
102115
choices=CircuitStatusChoices,
103116
null_value=None
104117
)
105-
site_id = django_filters.ModelMultipleChoiceFilter(
106-
field_name='terminations__site',
107-
queryset=Site.objects.all(),
108-
label='Site (ID)',
109-
)
110-
site = django_filters.ModelMultipleChoiceFilter(
111-
field_name='terminations__site__slug',
112-
queryset=Site.objects.all(),
113-
to_field_name='slug',
114-
label='Site (slug)',
115-
)
116118
region_id = TreeNodeMultipleChoiceFilter(
117119
queryset=Region.objects.all(),
118120
field_name='terminations__site__region',
@@ -126,6 +128,30 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
126128
to_field_name='slug',
127129
label='Region (slug)',
128130
)
131+
site_group_id = TreeNodeMultipleChoiceFilter(
132+
queryset=SiteGroup.objects.all(),
133+
field_name='terminations__site__group',
134+
lookup_expr='in',
135+
label='Site group (ID)',
136+
)
137+
site_group = TreeNodeMultipleChoiceFilter(
138+
queryset=SiteGroup.objects.all(),
139+
field_name='terminations__site__group',
140+
lookup_expr='in',
141+
to_field_name='slug',
142+
label='Site group (slug)',
143+
)
144+
site_id = django_filters.ModelMultipleChoiceFilter(
145+
field_name='terminations__site',
146+
queryset=Site.objects.all(),
147+
label='Site (ID)',
148+
)
149+
site = django_filters.ModelMultipleChoiceFilter(
150+
field_name='terminations__site__slug',
151+
queryset=Site.objects.all(),
152+
to_field_name='slug',
153+
label='Site (slug)',
154+
)
129155
tag = TagFilter()
130156

131157
class Meta:

netbox/circuits/forms.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django import forms
22
from django.utils.translation import gettext as _
33

4-
from dcim.models import Region, Site
4+
from dcim.models import Region, Site, SiteGroup
55
from extras.forms import (
66
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
77
)
@@ -320,18 +320,26 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
320320
'sites': '$site'
321321
}
322322
)
323+
site_group = DynamicModelChoiceField(
324+
queryset=SiteGroup.objects.all(),
325+
required=False,
326+
initial_params={
327+
'sites': '$site'
328+
}
329+
)
323330
site = DynamicModelChoiceField(
324331
queryset=Site.objects.all(),
325332
query_params={
326-
'region_id': '$region'
333+
'region_id': '$region',
334+
'group_id': '$site_group',
327335
}
328336
)
329337

330338
class Meta:
331339
model = CircuitTermination
332340
fields = [
333-
'term_side', 'region', 'site', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
334-
'description',
341+
'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed',
342+
'xconnect_id', 'pp_info', 'description',
335343
]
336344
help_texts = {
337345
'port_speed': "Physical circuit speed",

netbox/circuits/tests/test_filters.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from circuits.choices import *
44
from circuits.filters import *
55
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
6-
from dcim.models import Cable, Region, Site
6+
from dcim.models import Cable, Region, Site, SiteGroup
77
from tenancy.models import Tenant, TenantGroup
88

99

@@ -27,13 +27,20 @@ def setUpTestData(cls):
2727
Region(name='Test Region 1', slug='test-region-1'),
2828
Region(name='Test Region 2', slug='test-region-2'),
2929
)
30-
# Can't use bulk_create for models with MPTT fields
3130
for r in regions:
3231
r.save()
3332

33+
site_groups = (
34+
SiteGroup(name='Site Group 1', slug='site-group-1'),
35+
SiteGroup(name='Site Group 2', slug='site-group-2'),
36+
SiteGroup(name='Site Group 3', slug='site-group-3'),
37+
)
38+
for site_group in site_groups:
39+
site_group.save()
40+
3441
sites = (
35-
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
36-
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
42+
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
43+
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
3744
)
3845
Site.objects.bulk_create(sites)
3946

@@ -74,20 +81,27 @@ def test_account(self):
7481
params = {'account': ['1234', '2345']}
7582
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
7683

77-
def test_site(self):
78-
sites = Site.objects.all()[:2]
79-
params = {'site_id': [sites[0].pk, sites[1].pk]}
80-
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
81-
params = {'site': [sites[0].slug, sites[1].slug]}
82-
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
83-
8484
def test_region(self):
8585
regions = Region.objects.all()[:2]
8686
params = {'region_id': [regions[0].pk, regions[1].pk]}
8787
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
8888
params = {'region': [regions[0].slug, regions[1].slug]}
8989
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
9090

91+
def test_site_group(self):
92+
site_groups = SiteGroup.objects.all()[:2]
93+
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
94+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
95+
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
96+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
97+
98+
def test_site(self):
99+
sites = Site.objects.all()[:2]
100+
params = {'site_id': [sites[0].pk, sites[1].pk]}
101+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
102+
params = {'site': [sites[0].slug, sites[1].slug]}
103+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
104+
91105

92106
class CircuitTypeTestCase(TestCase):
93107
queryset = CircuitType.objects.all()
@@ -127,14 +141,21 @@ def setUpTestData(cls):
127141
Region(name='Test Region 2', slug='test-region-2'),
128142
Region(name='Test Region 3', slug='test-region-3'),
129143
)
130-
# Can't use bulk_create for models with MPTT fields
131144
for r in regions:
132145
r.save()
133146

147+
site_groups = (
148+
SiteGroup(name='Site Group 1', slug='site-group-1'),
149+
SiteGroup(name='Site Group 2', slug='site-group-2'),
150+
SiteGroup(name='Site Group 3', slug='site-group-3'),
151+
)
152+
for site_group in site_groups:
153+
site_group.save()
154+
134155
sites = (
135-
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
136-
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
137-
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
156+
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
157+
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
158+
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
138159
)
139160
Site.objects.bulk_create(sites)
140161

@@ -223,6 +244,13 @@ def test_region(self):
223244
params = {'region': [regions[0].slug, regions[1].slug]}
224245
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
225246

247+
def test_site_group(self):
248+
site_groups = SiteGroup.objects.all()[:2]
249+
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
250+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
251+
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
252+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
253+
226254
def test_site(self):
227255
sites = Site.objects.all()[:2]
228256
params = {'site_id': [sites[0].pk, sites[1].pk]}

netbox/dcim/api/nested_serializers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
'NestedRearPortTemplateSerializer',
3636
'NestedRegionSerializer',
3737
'NestedSiteSerializer',
38+
'NestedSiteGroupSerializer',
3839
'NestedVirtualChassisSerializer',
3940
]
4041

@@ -53,6 +54,16 @@ class Meta:
5354
fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
5455

5556

57+
class NestedSiteGroupSerializer(WritableNestedSerializer):
58+
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
59+
site_count = serializers.IntegerField(read_only=True)
60+
_depth = serializers.IntegerField(source='level', read_only=True)
61+
62+
class Meta:
63+
model = models.SiteGroup
64+
fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
65+
66+
5667
class NestedSiteSerializer(WritableNestedSerializer):
5768
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
5869

netbox/dcim/api/serializers.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,7 @@
66

77
from dcim.choices import *
88
from dcim.constants import *
9-
from dcim.models import (
10-
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
11-
DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
12-
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
13-
PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
14-
VirtualChassis,
15-
)
9+
from dcim.models import *
1610
from netbox.api.serializers import CustomFieldModelSerializer
1711
from extras.api.serializers import TaggedObjectSerializer
1812
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
@@ -94,10 +88,24 @@ class Meta:
9488
]
9589

9690

91+
class SiteGroupSerializer(NestedGroupModelSerializer):
92+
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
93+
parent = NestedRegionSerializer(required=False, allow_null=True)
94+
site_count = serializers.IntegerField(read_only=True)
95+
96+
class Meta:
97+
model = SiteGroup
98+
fields = [
99+
'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
100+
'site_count', '_depth',
101+
]
102+
103+
97104
class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
98105
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
99106
status = ChoiceField(choices=SiteStatusChoices, required=False)
100107
region = NestedRegionSerializer(required=False, allow_null=True)
108+
group = NestedSiteGroupSerializer(required=False, allow_null=True)
101109
tenant = NestedTenantSerializer(required=False, allow_null=True)
102110
time_zone = TimeZoneField(required=False)
103111
circuit_count = serializers.IntegerField(read_only=True)
@@ -110,10 +118,10 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
110118
class Meta:
111119
model = Site
112120
fields = [
113-
'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
114-
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
115-
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
116-
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
121+
'id', 'url', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone',
122+
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
123+
'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
124+
'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
117125
]
118126

119127

netbox/dcim/api/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
# Sites
99
router.register('regions', views.RegionViewSet)
10+
router.register('site-groups', views.SiteGroupViewSet)
1011
router.register('sites', views.SiteViewSet)
1112

1213
# Racks

netbox/dcim/api/views.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,7 @@
1616

1717
from circuits.models import Circuit
1818
from dcim import filters
19-
from dcim.models import (
20-
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
21-
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
22-
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
23-
PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
24-
VirtualChassis,
25-
)
19+
from dcim.models import *
2620
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
2721
from ipam.models import Prefix, VLAN
2822
from netbox.api.views import ModelViewSet
@@ -111,6 +105,22 @@ class RegionViewSet(CustomFieldModelViewSet):
111105
filterset_class = filters.RegionFilterSet
112106

113107

108+
#
109+
# Site groups
110+
#
111+
112+
class SiteGroupViewSet(CustomFieldModelViewSet):
113+
queryset = SiteGroup.objects.add_related_count(
114+
SiteGroup.objects.all(),
115+
Site,
116+
'group',
117+
'site_count',
118+
cumulative=True
119+
)
120+
serializer_class = serializers.SiteGroupSerializer
121+
filterset_class = filters.SiteGroupFilterSet
122+
123+
114124
#
115125
# Sites
116126
#

0 commit comments

Comments
 (0)