sites.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. from django.contrib.contenttypes.fields import GenericRelation
  2. from django.core.exceptions import ValidationError
  3. from django.db import models
  4. from django.urls import reverse
  5. from mptt.models import TreeForeignKey
  6. from timezone_field import TimeZoneField
  7. from dcim.choices import *
  8. from dcim.constants import *
  9. from netbox.models import NestedGroupModel, NetBoxModel
  10. from utilities.fields import NaturalOrderingField
  11. __all__ = (
  12. 'Location',
  13. 'Region',
  14. 'Site',
  15. 'SiteGroup',
  16. )
  17. #
  18. # Regions
  19. #
  20. class Region(NestedGroupModel):
  21. """
  22. A region represents a geographic collection of sites. For example, you might create regions representing countries,
  23. states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
  24. also considered to be members of its parent and ancestor region(s).
  25. """
  26. parent = TreeForeignKey(
  27. to='self',
  28. on_delete=models.CASCADE,
  29. related_name='children',
  30. blank=True,
  31. null=True,
  32. db_index=True
  33. )
  34. name = models.CharField(
  35. max_length=100
  36. )
  37. slug = models.SlugField(
  38. max_length=100
  39. )
  40. description = models.CharField(
  41. max_length=200,
  42. blank=True
  43. )
  44. # Generic relations
  45. vlan_groups = GenericRelation(
  46. to='ipam.VLANGroup',
  47. content_type_field='scope_type',
  48. object_id_field='scope_id',
  49. related_query_name='region'
  50. )
  51. contacts = GenericRelation(
  52. to='tenancy.ContactAssignment'
  53. )
  54. class Meta:
  55. constraints = (
  56. models.UniqueConstraint(
  57. fields=('parent', 'name'),
  58. name='dcim_region_parent_name'
  59. ),
  60. models.UniqueConstraint(
  61. fields=('name',),
  62. name='dcim_region_name',
  63. condition=Q(parent=None)
  64. ),
  65. models.UniqueConstraint(
  66. fields=('parent', 'slug'),
  67. name='dcim_region_parent_slug'
  68. ),
  69. models.UniqueConstraint(
  70. fields=('slug',),
  71. name='dcim_region_slug',
  72. condition=Q(parent=None)
  73. ),
  74. )
  75. def validate_unique(self, exclude=None):
  76. if self.parent is None:
  77. regions = Region.objects.exclude(pk=self.pk)
  78. if regions.filter(name=self.name, parent__isnull=True).exists():
  79. raise ValidationError({
  80. 'name': 'A region with this name already exists.'
  81. })
  82. if regions.filter(slug=self.slug, parent__isnull=True).exists():
  83. raise ValidationError({
  84. 'name': 'A region with this slug already exists.'
  85. })
  86. super().validate_unique(exclude=exclude)
  87. def get_absolute_url(self):
  88. return reverse('dcim:region', args=[self.pk])
  89. def get_site_count(self):
  90. return Site.objects.filter(
  91. Q(region=self) |
  92. Q(region__in=self.get_descendants())
  93. ).count()
  94. #
  95. # Site groups
  96. #
  97. class SiteGroup(NestedGroupModel):
  98. """
  99. A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
  100. within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
  101. nested recursively to form a hierarchy.
  102. """
  103. parent = TreeForeignKey(
  104. to='self',
  105. on_delete=models.CASCADE,
  106. related_name='children',
  107. blank=True,
  108. null=True,
  109. db_index=True
  110. )
  111. name = models.CharField(
  112. max_length=100
  113. )
  114. slug = models.SlugField(
  115. max_length=100
  116. )
  117. description = models.CharField(
  118. max_length=200,
  119. blank=True
  120. )
  121. # Generic relations
  122. vlan_groups = GenericRelation(
  123. to='ipam.VLANGroup',
  124. content_type_field='scope_type',
  125. object_id_field='scope_id',
  126. related_query_name='site_group'
  127. )
  128. contacts = GenericRelation(
  129. to='tenancy.ContactAssignment'
  130. )
  131. class Meta:
  132. constraints = (
  133. models.UniqueConstraint(
  134. fields=('parent', 'name'),
  135. name='dcim_sitegroup_parent_name'
  136. ),
  137. models.UniqueConstraint(
  138. fields=('name',),
  139. name='dcim_sitegroup_name',
  140. condition=Q(parent=None)
  141. ),
  142. models.UniqueConstraint(
  143. fields=('parent', 'slug'),
  144. name='dcim_sitegroup_parent_slug'
  145. ),
  146. models.UniqueConstraint(
  147. fields=('slug',),
  148. name='dcim_sitegroup_slug',
  149. condition=Q(parent=None)
  150. ),
  151. )
  152. def validate_unique(self, exclude=None):
  153. if self.parent is None:
  154. site_groups = SiteGroup.objects.exclude(pk=self.pk)
  155. if site_groups.filter(name=self.name, parent__isnull=True).exists():
  156. raise ValidationError({
  157. 'name': 'A site group with this name already exists.'
  158. })
  159. if site_groups.filter(slug=self.slug, parent__isnull=True).exists():
  160. raise ValidationError({
  161. 'name': 'A site group with this slug already exists.'
  162. })
  163. super().validate_unique(exclude=exclude)
  164. def get_absolute_url(self):
  165. return reverse('dcim:sitegroup', args=[self.pk])
  166. def get_site_count(self):
  167. return Site.objects.filter(
  168. Q(group=self) |
  169. Q(group__in=self.get_descendants())
  170. ).count()
  171. #
  172. # Sites
  173. #
  174. class Site(NetBoxModel):
  175. """
  176. A Site represents a geographic location within a network; typically a building or campus. The optional facility
  177. field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
  178. """
  179. name = models.CharField(
  180. max_length=100,
  181. unique=True
  182. )
  183. _name = NaturalOrderingField(
  184. target_field='name',
  185. max_length=100,
  186. blank=True
  187. )
  188. slug = models.SlugField(
  189. max_length=100,
  190. unique=True
  191. )
  192. status = models.CharField(
  193. max_length=50,
  194. choices=SiteStatusChoices,
  195. default=SiteStatusChoices.STATUS_ACTIVE
  196. )
  197. region = models.ForeignKey(
  198. to='dcim.Region',
  199. on_delete=models.SET_NULL,
  200. related_name='sites',
  201. blank=True,
  202. null=True
  203. )
  204. group = models.ForeignKey(
  205. to='dcim.SiteGroup',
  206. on_delete=models.SET_NULL,
  207. related_name='sites',
  208. blank=True,
  209. null=True
  210. )
  211. tenant = models.ForeignKey(
  212. to='tenancy.Tenant',
  213. on_delete=models.PROTECT,
  214. related_name='sites',
  215. blank=True,
  216. null=True
  217. )
  218. facility = models.CharField(
  219. max_length=50,
  220. blank=True,
  221. help_text='Local facility ID or description'
  222. )
  223. asns = models.ManyToManyField(
  224. to='ipam.ASN',
  225. related_name='sites',
  226. blank=True
  227. )
  228. time_zone = TimeZoneField(
  229. blank=True
  230. )
  231. description = models.CharField(
  232. max_length=200,
  233. blank=True
  234. )
  235. physical_address = models.CharField(
  236. max_length=200,
  237. blank=True
  238. )
  239. shipping_address = models.CharField(
  240. max_length=200,
  241. blank=True
  242. )
  243. latitude = models.DecimalField(
  244. max_digits=8,
  245. decimal_places=6,
  246. blank=True,
  247. null=True,
  248. help_text='GPS coordinate (latitude)'
  249. )
  250. longitude = models.DecimalField(
  251. max_digits=9,
  252. decimal_places=6,
  253. blank=True,
  254. null=True,
  255. help_text='GPS coordinate (longitude)'
  256. )
  257. comments = models.TextField(
  258. blank=True
  259. )
  260. # Generic relations
  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. contacts = GenericRelation(
  268. to='tenancy.ContactAssignment'
  269. )
  270. images = GenericRelation(
  271. to='extras.ImageAttachment'
  272. )
  273. clone_fields = (
  274. 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address',
  275. 'latitude', 'longitude', 'description',
  276. )
  277. class Meta:
  278. ordering = ('_name',)
  279. def __str__(self):
  280. return self.name
  281. def get_absolute_url(self):
  282. return reverse('dcim:site', args=[self.pk])
  283. def get_status_color(self):
  284. return SiteStatusChoices.colors.get(self.status)
  285. #
  286. # Locations
  287. #
  288. class Location(NestedGroupModel):
  289. """
  290. A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
  291. site, or a room within a building, for example.
  292. """
  293. name = models.CharField(
  294. max_length=100
  295. )
  296. slug = models.SlugField(
  297. max_length=100
  298. )
  299. site = models.ForeignKey(
  300. to='dcim.Site',
  301. on_delete=models.CASCADE,
  302. related_name='locations'
  303. )
  304. parent = TreeForeignKey(
  305. to='self',
  306. on_delete=models.CASCADE,
  307. related_name='children',
  308. blank=True,
  309. null=True,
  310. db_index=True
  311. )
  312. status = models.CharField(
  313. max_length=50,
  314. choices=LocationStatusChoices,
  315. default=LocationStatusChoices.STATUS_ACTIVE
  316. )
  317. tenant = models.ForeignKey(
  318. to='tenancy.Tenant',
  319. on_delete=models.PROTECT,
  320. related_name='locations',
  321. blank=True,
  322. null=True
  323. )
  324. description = models.CharField(
  325. max_length=200,
  326. blank=True
  327. )
  328. # Generic relations
  329. vlan_groups = GenericRelation(
  330. to='ipam.VLANGroup',
  331. content_type_field='scope_type',
  332. object_id_field='scope_id',
  333. related_query_name='location'
  334. )
  335. contacts = GenericRelation(
  336. to='tenancy.ContactAssignment'
  337. )
  338. images = GenericRelation(
  339. to='extras.ImageAttachment'
  340. )
  341. clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
  342. class Meta:
  343. ordering = ['site', 'name']
  344. constraints = (
  345. models.UniqueConstraint(
  346. fields=('site', 'parent', 'name'),
  347. name='dcim_location_parent_name'
  348. ),
  349. models.UniqueConstraint(
  350. fields=('site', 'name'),
  351. name='dcim_location_name',
  352. condition=Q(parent=None)
  353. ),
  354. models.UniqueConstraint(
  355. fields=('site', 'parent', 'slug'),
  356. name='dcim_location_parent_slug'
  357. ),
  358. models.UniqueConstraint(
  359. fields=('site', 'slug'),
  360. name='dcim_location_slug',
  361. condition=Q(parent=None)
  362. ),
  363. )
  364. def validate_unique(self, exclude=None):
  365. if self.parent is None:
  366. locations = Location.objects.exclude(pk=self.pk)
  367. if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists():
  368. raise ValidationError({
  369. "name": f"A location with this name in site {self.site} already exists."
  370. })
  371. if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists():
  372. raise ValidationError({
  373. "name": f"A location with this slug in site {self.site} already exists."
  374. })
  375. super().validate_unique(exclude=exclude)
  376. def get_absolute_url(self):
  377. return reverse('dcim:location', args=[self.pk])
  378. def get_status_color(self):
  379. return LocationStatusChoices.colors.get(self.status)
  380. def clean(self):
  381. super().clean()
  382. # Parent Location (if any) must belong to the same Site
  383. if self.parent and self.parent.site != self.site:
  384. raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")