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

1import decimal 

2 

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 

10 

11from dcim.choices import * 

12from dcim.constants import * 

13from netbox.models import NestedLtreeGroupModel, PrimaryModel 

14from netbox.models.features import ContactsMixin, ImageAttachmentsMixin 

15 

16__all__ = ( 

17 'Location', 

18 'Region', 

19 'Site', 

20 'SiteGroup', 

21) 

22 

23 

24# 

25# Regions 

26# 

27 

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 ) 

58 

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') 

81 

82 def get_site_count(self): 

83 return Site.objects.filter( 

84 Q(region=self) | 

85 Q(region__in=self.get_descendants()) 

86 ).count() 

87 

88 

89# 

90# Site groups 

91# 

92 

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 ) 

123 

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') 

146 

147 def get_site_count(self): 

148 return Site.objects.filter( 

149 Q(group=self) | 

150 Q(group__in=self.get_descendants()) 

151 ).count() 

152 

153 

154# 

155# Sites 

156# 

157 

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 ) 

253 

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 ) 

267 

268 clone_fields = ( 

269 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address', 

270 'latitude', 'longitude', 'description', 

271 ) 

272 

273 class Meta: 

274 ordering = ('name',) 

275 verbose_name = _('site') 

276 verbose_name_plural = _('sites') 

277 

278 def __str__(self): 

279 return self.name 

280 

281 def get_status_color(self): 

282 return SiteStatusChoices.colors.get(self.status) 

283 

284 

285# 

286# Locations 

287# 

288 

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 ) 

318 

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 ) 

332 

333 clone_fields = ('site', 'parent', 'status', 'tenant', 'facility', 'description') 

334 prerequisite_models = ( 

335 'dcim.Site', 

336 ) 

337 

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') 

365 

366 def get_status_color(self): 

367 return LocationStatusChoices.colors.get(self.status) 

368 

369 def clean(self): 

370 super().clean() 

371 

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))