modules.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import jsonschema
  2. import yaml
  3. from django.core.exceptions import ValidationError
  4. from django.db import models
  5. from django.db.models.signals import post_save
  6. from django.utils.translation import gettext_lazy as _
  7. from jsonschema.exceptions import ValidationError as JSONValidationError
  8. from dcim.choices import *
  9. from dcim.utils import update_interface_bridges
  10. from extras.models import ConfigContextModel, CustomField
  11. from netbox.models import PrimaryModel
  12. from netbox.models.features import ImageAttachmentsMixin
  13. from netbox.models.mixins import WeightMixin
  14. from utilities.fields import CounterCacheField
  15. from utilities.jsonschema import validate_schema
  16. from utilities.string import title
  17. from utilities.tracking import TrackingModelMixin
  18. from .device_components import *
  19. __all__ = (
  20. 'Module',
  21. 'ModuleType',
  22. 'ModuleTypeProfile',
  23. )
  24. class ModuleTypeProfile(PrimaryModel):
  25. """
  26. A profile which defines the attributes which can be set on one or more ModuleTypes.
  27. """
  28. name = models.CharField(
  29. verbose_name=_('name'),
  30. max_length=100,
  31. unique=True
  32. )
  33. schema = models.JSONField(
  34. blank=True,
  35. null=True,
  36. validators=[validate_schema],
  37. verbose_name=_('schema'),
  38. )
  39. clone_fields = ('schema',)
  40. class Meta:
  41. ordering = ('name',)
  42. verbose_name = _('module type profile')
  43. verbose_name_plural = _('module type profiles')
  44. def __str__(self):
  45. return self.name
  46. class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
  47. """
  48. A ModuleType represents a hardware element that can be installed within a device and which houses additional
  49. components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
  50. DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
  51. cannot, however house device bays or module bays.
  52. """
  53. profile = models.ForeignKey(
  54. to='dcim.ModuleTypeProfile',
  55. on_delete=models.PROTECT,
  56. related_name='module_types',
  57. blank=True,
  58. null=True
  59. )
  60. manufacturer = models.ForeignKey(
  61. to='dcim.Manufacturer',
  62. on_delete=models.PROTECT,
  63. related_name='module_types'
  64. )
  65. model = models.CharField(
  66. verbose_name=_('model'),
  67. max_length=100
  68. )
  69. part_number = models.CharField(
  70. verbose_name=_('part number'),
  71. max_length=50,
  72. blank=True,
  73. help_text=_('Discrete part number (optional)')
  74. )
  75. airflow = models.CharField(
  76. verbose_name=_('airflow'),
  77. max_length=50,
  78. choices=ModuleAirflowChoices,
  79. blank=True,
  80. null=True
  81. )
  82. attribute_data = models.JSONField(
  83. blank=True,
  84. null=True,
  85. verbose_name=_('attributes')
  86. )
  87. module_count = CounterCacheField(
  88. to_model='dcim.Module',
  89. to_field='module_type'
  90. )
  91. clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
  92. prerequisite_models = (
  93. 'dcim.Manufacturer',
  94. )
  95. class Meta:
  96. ordering = ('profile', 'manufacturer', 'model')
  97. constraints = (
  98. models.UniqueConstraint(
  99. fields=('manufacturer', 'model'),
  100. name='%(app_label)s_%(class)s_unique_manufacturer_model'
  101. ),
  102. )
  103. verbose_name = _('module type')
  104. verbose_name_plural = _('module types')
  105. def __str__(self):
  106. return self.model
  107. @property
  108. def full_name(self):
  109. return f"{self.manufacturer} {self.model}"
  110. @property
  111. def attributes(self):
  112. """
  113. Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile.
  114. """
  115. if not self.attribute_data or self.profile is None or not self.profile.schema:
  116. return {}
  117. attrs = {}
  118. for name, options in self.profile.schema.get('properties', {}).items():
  119. key = options.get('title', title(name))
  120. attrs[key] = self.attribute_data.get(name)
  121. return dict(sorted(attrs.items()))
  122. def clean(self):
  123. super().clean()
  124. # Validate any attributes against the assigned profile's schema
  125. if self.profile and self.profile.schema:
  126. try:
  127. jsonschema.validate(self.attribute_data, schema=self.profile.schema)
  128. except JSONValidationError as e:
  129. raise ValidationError(_("Invalid schema: {error}").format(error=e))
  130. else:
  131. self.attribute_data = None
  132. def to_yaml(self):
  133. data = {
  134. 'profile': self.profile.name if self.profile else None,
  135. 'manufacturer': self.manufacturer.name,
  136. 'model': self.model,
  137. 'part_number': self.part_number,
  138. 'description': self.description,
  139. 'weight': float(self.weight) if self.weight is not None else None,
  140. 'weight_unit': self.weight_unit,
  141. 'comments': self.comments,
  142. }
  143. # Component templates
  144. if self.consoleporttemplates.exists():
  145. data['console-ports'] = [
  146. c.to_yaml() for c in self.consoleporttemplates.all()
  147. ]
  148. if self.consoleserverporttemplates.exists():
  149. data['console-server-ports'] = [
  150. c.to_yaml() for c in self.consoleserverporttemplates.all()
  151. ]
  152. if self.powerporttemplates.exists():
  153. data['power-ports'] = [
  154. c.to_yaml() for c in self.powerporttemplates.all()
  155. ]
  156. if self.poweroutlettemplates.exists():
  157. data['power-outlets'] = [
  158. c.to_yaml() for c in self.poweroutlettemplates.all()
  159. ]
  160. if self.interfacetemplates.exists():
  161. data['interfaces'] = [
  162. c.to_yaml() for c in self.interfacetemplates.all()
  163. ]
  164. if self.frontporttemplates.exists():
  165. data['front-ports'] = [
  166. c.to_yaml() for c in self.frontporttemplates.all()
  167. ]
  168. if self.rearporttemplates.exists():
  169. data['rear-ports'] = [
  170. c.to_yaml() for c in self.rearporttemplates.all()
  171. ]
  172. return yaml.dump(dict(data), sort_keys=False)
  173. class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
  174. """
  175. A Module represents a field-installable component within a Device which may itself hold multiple device components
  176. (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
  177. """
  178. device = models.ForeignKey(
  179. to='dcim.Device',
  180. on_delete=models.CASCADE,
  181. related_name='modules'
  182. )
  183. module_bay = models.OneToOneField(
  184. to='dcim.ModuleBay',
  185. on_delete=models.CASCADE,
  186. related_name='installed_module'
  187. )
  188. module_type = models.ForeignKey(
  189. to='dcim.ModuleType',
  190. on_delete=models.PROTECT,
  191. related_name='instances'
  192. )
  193. status = models.CharField(
  194. verbose_name=_('status'),
  195. max_length=50,
  196. choices=ModuleStatusChoices,
  197. default=ModuleStatusChoices.STATUS_ACTIVE
  198. )
  199. serial = models.CharField(
  200. max_length=50,
  201. blank=True,
  202. verbose_name=_('serial number')
  203. )
  204. asset_tag = models.CharField(
  205. max_length=50,
  206. blank=True,
  207. null=True,
  208. unique=True,
  209. verbose_name=_('asset tag'),
  210. help_text=_('A unique tag used to identify this device')
  211. )
  212. clone_fields = ('device', 'module_type', 'status')
  213. class Meta:
  214. ordering = ('module_bay',)
  215. verbose_name = _('module')
  216. verbose_name_plural = _('modules')
  217. def __str__(self):
  218. return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
  219. def get_status_color(self):
  220. return ModuleStatusChoices.colors.get(self.status)
  221. def clean(self):
  222. super().clean()
  223. if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
  224. raise ValidationError(
  225. _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
  226. device=self.device
  227. )
  228. )
  229. # Check for recursion
  230. module = self
  231. module_bays = []
  232. modules = []
  233. while module:
  234. if module.pk in modules or module.module_bay.pk in module_bays:
  235. raise ValidationError(_("A module bay cannot belong to a module installed within it."))
  236. modules.append(module.pk)
  237. module_bays.append(module.module_bay.pk)
  238. module = module.module_bay.module if module.module_bay else None
  239. def save(self, *args, **kwargs):
  240. is_new = self.pk is None
  241. super().save(*args, **kwargs)
  242. adopt_components = getattr(self, '_adopt_components', False)
  243. disable_replication = getattr(self, '_disable_replication', False)
  244. # We skip adding components if the module is being edited or
  245. # both replication and component adoption is disabled
  246. if not is_new or (disable_replication and not adopt_components):
  247. return
  248. # Iterate all component types
  249. for templates, component_attribute, component_model in [
  250. ("consoleporttemplates", "consoleports", ConsolePort),
  251. ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
  252. ("interfacetemplates", "interfaces", Interface),
  253. ("powerporttemplates", "powerports", PowerPort),
  254. ("poweroutlettemplates", "poweroutlets", PowerOutlet),
  255. ("rearporttemplates", "rearports", RearPort),
  256. ("frontporttemplates", "frontports", FrontPort),
  257. ("modulebaytemplates", "modulebays", ModuleBay),
  258. ]:
  259. create_instances = []
  260. update_instances = []
  261. # Prefetch installed components
  262. installed_components = {
  263. component.name: component
  264. for component in getattr(self.device, component_attribute).filter(module__isnull=True)
  265. }
  266. # Get the template for the module type.
  267. for template in getattr(self.module_type, templates).all():
  268. template_instance = template.instantiate(device=self.device, module=self)
  269. if adopt_components:
  270. existing_item = installed_components.get(template_instance.name)
  271. # Check if there's a component with the same name already
  272. if existing_item:
  273. # Assign it to the module
  274. existing_item.module = self
  275. update_instances.append(existing_item)
  276. continue
  277. # Only create new components if replication is enabled
  278. if not disable_replication:
  279. create_instances.append(template_instance)
  280. # Set default values for any applicable custom fields
  281. if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
  282. for component in create_instances:
  283. component.custom_field_data = cf_defaults
  284. # Set denormalized references
  285. for component in create_instances:
  286. component._site = self.device.site
  287. component._location = self.device.location
  288. component._rack = self.device.rack
  289. if component_model is not ModuleBay:
  290. component_model.objects.bulk_create(create_instances)
  291. # Emit the post_save signal for each newly created object
  292. for component in create_instances:
  293. post_save.send(
  294. sender=component_model,
  295. instance=component,
  296. created=True,
  297. raw=False,
  298. using='default',
  299. update_fields=None
  300. )
  301. else:
  302. # ModuleBays must be saved individually for MPTT
  303. for instance in create_instances:
  304. instance.save()
  305. update_fields = ['module']
  306. component_model.objects.bulk_update(update_instances, update_fields)
  307. # Emit the post_save signal for each updated object
  308. for component in update_instances:
  309. post_save.send(
  310. sender=component_model,
  311. instance=component,
  312. created=False,
  313. raw=False,
  314. using='default',
  315. update_fields=update_fields
  316. )
  317. # Interface bridges have to be set after interface instantiation
  318. update_interface_bridges(self.device, self.module_type.interfacetemplates, self)