modules.py 12 KB

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