Coverage for dcim/models/sites.py: 90%
85 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import decimal
3from django.contrib.contenttypes.fields import GenericRelation
4from django.contrib.postgres.indexes import GistIndex
5from django.core.exceptions import ValidationError
6from django.core.validators import MaxValueValidator, MinValueValidator
7from django.db import models
8from django.utils.translation import gettext_lazy as _
9from timezone_field import TimeZoneField
11from dcim.choices import *
12from dcim.constants import *
13from netbox.models import NestedLtreeGroupModel, PrimaryModel
14from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
16__all__ = (
17 'Location',
18 'Region',
19 'Site',
20 'SiteGroup',
21)
24#
25# Regions
26#
28class Region(ContactsMixin, NestedLtreeGroupModel):
29 """
30 A region represents a geographic collection of sites. For example, you might create regions representing countries,
31 states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
32 also considered to be members of its parent and ancestor region(s).
33 """
34 prefixes = GenericRelation(
35 to='ipam.Prefix',
36 content_type_field='scope_type',
37 object_id_field='scope_id',
38 related_query_name='region'
39 )
40 vlan_groups = GenericRelation(
41 to='ipam.VLANGroup',
42 content_type_field='scope_type',
43 object_id_field='scope_id',
44 related_query_name='region'
45 )
46 clusters = GenericRelation(
47 to='virtualization.Cluster',
48 content_type_field='scope_type',
49 object_id_field='scope_id',
50 related_query_name='region'
51 )
52 wireless_lans = GenericRelation(
53 to='wireless.WirelessLAN',
54 content_type_field='scope_type',
55 object_id_field='scope_id',
56 related_query_name='region'
57 )
59 class Meta:
60 ordering = ('sort_path',)
61 indexes = (
62 GistIndex(fields=['path'], name='dcim_region_path_gist'),
63 models.Index(fields=['sort_path'], name='dcim_region_sort_path_idx'),
64 )
65 constraints = (
66 models.UniqueConstraint(
67 fields=('parent', 'name'),
68 name='%(app_label)s_%(class)s_parent_name',
69 nulls_distinct=False,
70 violation_error_message=_("A region with this name already exists.")
71 ),
72 models.UniqueConstraint(
73 fields=('parent', 'slug'),
74 name='%(app_label)s_%(class)s_parent_slug',
75 nulls_distinct=False,
76 violation_error_message=_("A region with this slug already exists.")
77 ),
78 )
79 verbose_name = _('region')
80 verbose_name_plural = _('regions')
82 def get_site_count(self):
83 return Site.objects.filter(
84 Q(region=self) |
85 Q(region__in=self.get_descendants())
86 ).count()
89#
90# Site groups
91#
93class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
94 """
95 A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
96 within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
97 nested recursively to form a hierarchy.
98 """
99 prefixes = GenericRelation(
100 to='ipam.Prefix',
101 content_type_field='scope_type',
102 object_id_field='scope_id',
103 related_query_name='site_group'
104 )
105 vlan_groups = GenericRelation(
106 to='ipam.VLANGroup',
107 content_type_field='scope_type',
108 object_id_field='scope_id',
109 related_query_name='site_group'
110 )
111 clusters = GenericRelation(
112 to='virtualization.Cluster',
113 content_type_field='scope_type',
114 object_id_field='scope_id',
115 related_query_name='site_group'
116 )
117 wireless_lans = GenericRelation(
118 to='wireless.WirelessLAN',
119 content_type_field='scope_type',
120 object_id_field='scope_id',
121 related_query_name='site_group'
122 )
124 class Meta:
125 ordering = ('sort_path',)
126 indexes = (
127 GistIndex(fields=['path'], name='dcim_sitegroup_path_gist'),
128 models.Index(fields=['sort_path'], name='dcim_sitegroup_sort_path_idx'),
129 )
130 constraints = (
131 models.UniqueConstraint(
132 fields=('parent', 'name'),
133 name='%(app_label)s_%(class)s_parent_name',
134 nulls_distinct=False,
135 violation_error_message=_("A site group with this name already exists.")
136 ),
137 models.UniqueConstraint(
138 fields=('parent', 'slug'),
139 name='%(app_label)s_%(class)s_parent_slug',
140 nulls_distinct=False,
141 violation_error_message=_("A site group with this slug already exists.")
142 ),
143 )
144 verbose_name = _('site group')
145 verbose_name_plural = _('site groups')
147 def get_site_count(self):
148 return Site.objects.filter(
149 Q(group=self) |
150 Q(group__in=self.get_descendants())
151 ).count()
154#
155# Sites
156#
158class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
159 """
160 A Site represents a geographic location within a network; typically a building or campus. The optional facility
161 field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
162 """
163 name = models.CharField(
164 verbose_name=_('name'),
165 max_length=100,
166 unique=True,
167 help_text=_("Full name of the site"),
168 db_collation="natural_sort"
169 )
170 slug = models.SlugField(
171 verbose_name=_('slug'),
172 max_length=100,
173 unique=True
174 )
175 status = models.CharField(
176 verbose_name=_('status'),
177 max_length=50,
178 choices=SiteStatusChoices,
179 default=SiteStatusChoices.STATUS_ACTIVE
180 )
181 region = models.ForeignKey(
182 to='dcim.Region',
183 on_delete=models.SET_NULL,
184 related_name='sites',
185 blank=True,
186 null=True
187 )
188 group = models.ForeignKey(
189 to='dcim.SiteGroup',
190 on_delete=models.SET_NULL,
191 related_name='sites',
192 blank=True,
193 null=True
194 )
195 tenant = models.ForeignKey(
196 to='tenancy.Tenant',
197 on_delete=models.PROTECT,
198 related_name='sites',
199 blank=True,
200 null=True
201 )
202 facility = models.CharField(
203 verbose_name=_('facility'),
204 max_length=50,
205 blank=True,
206 help_text=_('Local facility ID or description')
207 )
208 asns = models.ManyToManyField(
209 to='ipam.ASN',
210 related_name='sites',
211 blank=True
212 )
213 time_zone = TimeZoneField(
214 blank=True,
215 null=True
216 )
217 physical_address = models.CharField(
218 verbose_name=_('physical address'),
219 max_length=200,
220 blank=True,
221 help_text=_('Physical location of the building')
222 )
223 shipping_address = models.CharField(
224 verbose_name=_('shipping address'),
225 max_length=200,
226 blank=True,
227 help_text=_('If different from the physical address')
228 )
229 latitude = models.DecimalField(
230 verbose_name=_('latitude'),
231 max_digits=8,
232 decimal_places=6,
233 blank=True,
234 null=True,
235 validators=[
236 MinValueValidator(decimal.Decimal('-90.0')),
237 MaxValueValidator(decimal.Decimal('90.0'))
238 ],
239 help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
240 )
241 longitude = models.DecimalField(
242 verbose_name=_('longitude'),
243 max_digits=9,
244 decimal_places=6,
245 blank=True,
246 null=True,
247 validators=[
248 MinValueValidator(decimal.Decimal('-180.0')),
249 MaxValueValidator(decimal.Decimal('180.0'))
250 ],
251 help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
252 )
254 # Generic relations
255 prefixes = GenericRelation(
256 to='ipam.Prefix',
257 content_type_field='scope_type',
258 object_id_field='scope_id',
259 related_query_name='site'
260 )
261 vlan_groups = GenericRelation(
262 to='ipam.VLANGroup',
263 content_type_field='scope_type',
264 object_id_field='scope_id',
265 related_query_name='site'
266 )
268 clone_fields = (
269 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address',
270 'latitude', 'longitude', 'description',
271 )
273 class Meta:
274 ordering = ('name',)
275 verbose_name = _('site')
276 verbose_name_plural = _('sites')
278 def __str__(self):
279 return self.name
281 def get_status_color(self):
282 return SiteStatusChoices.colors.get(self.status)
285#
286# Locations
287#
289class Location(ContactsMixin, ImageAttachmentsMixin, NestedLtreeGroupModel):
290 """
291 A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
292 site, or a room within a building, for example.
293 """
294 site = models.ForeignKey(
295 to='dcim.Site',
296 on_delete=models.CASCADE,
297 related_name='locations'
298 )
299 status = models.CharField(
300 verbose_name=_('status'),
301 max_length=50,
302 choices=LocationStatusChoices,
303 default=LocationStatusChoices.STATUS_ACTIVE
304 )
305 tenant = models.ForeignKey(
306 to='tenancy.Tenant',
307 on_delete=models.PROTECT,
308 related_name='locations',
309 blank=True,
310 null=True
311 )
312 facility = models.CharField(
313 verbose_name=_('facility'),
314 max_length=50,
315 blank=True,
316 help_text=_('Local facility ID or description')
317 )
319 # Generic relations
320 prefixes = GenericRelation(
321 to='ipam.Prefix',
322 content_type_field='scope_type',
323 object_id_field='scope_id',
324 related_query_name='location'
325 )
326 vlan_groups = GenericRelation(
327 to='ipam.VLANGroup',
328 content_type_field='scope_type',
329 object_id_field='scope_id',
330 related_query_name='location'
331 )
333 clone_fields = ('site', 'parent', 'status', 'tenant', 'facility', 'description')
334 prerequisite_models = (
335 'dcim.Site',
336 )
338 class Meta:
339 # Group by site, then tree-flatten within each site. This mirrors the prior
340 # MPTT behavior (Meta.ordering = ['site', 'name']) while upgrading the
341 # within-site ordering to sort_path, so descendants follow their parent in
342 # name order. sort_path is unique within a site (root names are unique per
343 # site, child names unique per parent), so no further tie-break is needed.
344 ordering = ('site', 'sort_path')
345 indexes = (
346 GistIndex(fields=['path'], name='dcim_location_path_gist'),
347 models.Index(fields=['sort_path'], name='dcim_location_sort_path_idx'),
348 )
349 constraints = (
350 models.UniqueConstraint(
351 fields=('site', 'parent', 'name'),
352 name='%(app_label)s_%(class)s_parent_name',
353 nulls_distinct=False,
354 violation_error_message=_("A location with this name already exists within the specified site.")
355 ),
356 models.UniqueConstraint(
357 fields=('site', 'parent', 'slug'),
358 name='%(app_label)s_%(class)s_parent_slug',
359 nulls_distinct=False,
360 violation_error_message=_("A location with this slug already exists within the specified site.")
361 ),
362 )
363 verbose_name = _('location')
364 verbose_name_plural = _('locations')
366 def get_status_color(self):
367 return LocationStatusChoices.colors.get(self.status)
369 def clean(self):
370 super().clean()
372 # Parent Location (if any) must belong to the same Site
373 if self.parent and self.parent.site != self.site:
374 raise ValidationError(_(
375 "Parent location ({parent}) must belong to the same site ({site})."
376 ).format(parent=self.parent, site=self.site))