modules.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  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 create_port_mappings, 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. 'airflow': self.airflow,
  142. 'attribute_data': self.attribute_data,
  143. 'comments': self.comments,
  144. }
  145. # Component templates
  146. if self.consoleporttemplates.exists():
  147. data['console-ports'] = [
  148. c.to_yaml() for c in self.consoleporttemplates.all()
  149. ]
  150. if self.consoleserverporttemplates.exists():
  151. data['console-server-ports'] = [
  152. c.to_yaml() for c in self.consoleserverporttemplates.all()
  153. ]
  154. if self.powerporttemplates.exists():
  155. data['power-ports'] = [
  156. c.to_yaml() for c in self.powerporttemplates.all()
  157. ]
  158. if self.poweroutlettemplates.exists():
  159. data['power-outlets'] = [
  160. c.to_yaml() for c in self.poweroutlettemplates.all()
  161. ]
  162. if self.interfacetemplates.exists():
  163. data['interfaces'] = [
  164. c.to_yaml() for c in self.interfacetemplates.all()
  165. ]
  166. if self.frontporttemplates.exists():
  167. data['front-ports'] = [
  168. c.to_yaml() for c in self.frontporttemplates.all()
  169. ]
  170. if self.rearporttemplates.exists():
  171. data['rear-ports'] = [
  172. c.to_yaml() for c in self.rearporttemplates.all()
  173. ]
  174. return yaml.dump(dict(data), sort_keys=False)
  175. class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
  176. """
  177. A Module represents a field-installable component within a Device which may itself hold multiple device components
  178. (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
  179. """
  180. device = models.ForeignKey(
  181. to='dcim.Device',
  182. on_delete=models.CASCADE,
  183. related_name='modules'
  184. )
  185. module_bay = models.OneToOneField(
  186. to='dcim.ModuleBay',
  187. on_delete=models.CASCADE,
  188. related_name='installed_module'
  189. )
  190. module_type = models.ForeignKey(
  191. to='dcim.ModuleType',
  192. on_delete=models.PROTECT,
  193. related_name='instances'
  194. )
  195. status = models.CharField(
  196. verbose_name=_('status'),
  197. max_length=50,
  198. choices=ModuleStatusChoices,
  199. default=ModuleStatusChoices.STATUS_ACTIVE
  200. )
  201. serial = models.CharField(
  202. max_length=50,
  203. blank=True,
  204. verbose_name=_('serial number')
  205. )
  206. asset_tag = models.CharField(
  207. max_length=50,
  208. blank=True,
  209. null=True,
  210. unique=True,
  211. verbose_name=_('asset tag'),
  212. help_text=_('A unique tag used to identify this device')
  213. )
  214. clone_fields = ('device', 'module_type', 'status')
  215. class Meta:
  216. ordering = ('module_bay',)
  217. verbose_name = _('module')
  218. verbose_name_plural = _('modules')
  219. def __str__(self):
  220. return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
  221. def get_status_color(self):
  222. return ModuleStatusChoices.colors.get(self.status)
  223. def clean(self):
  224. super().clean()
  225. if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
  226. raise ValidationError(
  227. _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
  228. device=self.device
  229. )
  230. )
  231. # Check for recursion
  232. module = self
  233. module_bays = []
  234. modules = []
  235. while module:
  236. module_module_bay = getattr(module, "module_bay", None)
  237. if module.pk in modules or (module_module_bay and 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. if module_module_bay:
  241. module_bays.append(module_module_bay.pk)
  242. module = module_module_bay.module if module_module_bay else None
  243. def save(self, *args, **kwargs):
  244. is_new = self.pk is None
  245. super().save(*args, **kwargs)
  246. adopt_components = getattr(self, '_adopt_components', False)
  247. disable_replication = getattr(self, '_disable_replication', False)
  248. # We skip adding components if the module is being edited or
  249. # both replication and component adoption is disabled
  250. if not is_new or (disable_replication and not adopt_components):
  251. return
  252. # Iterate all component types
  253. for templates, component_attribute, component_model in [
  254. ("consoleporttemplates", "consoleports", ConsolePort),
  255. ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
  256. ("interfacetemplates", "interfaces", Interface),
  257. ("powerporttemplates", "powerports", PowerPort),
  258. ("poweroutlettemplates", "poweroutlets", PowerOutlet),
  259. ("rearporttemplates", "rearports", RearPort),
  260. ("frontporttemplates", "frontports", FrontPort),
  261. ("modulebaytemplates", "modulebays", ModuleBay),
  262. ]:
  263. create_instances = []
  264. update_instances = []
  265. # Prefetch installed components
  266. installed_components = {
  267. component.name: component
  268. for component in getattr(self.device, component_attribute).filter(module__isnull=True)
  269. }
  270. # Get the template for the module type.
  271. for template in getattr(self.module_type, templates).all():
  272. template_instance = template.instantiate(device=self.device, module=self)
  273. if adopt_components:
  274. existing_item = installed_components.get(template_instance.name)
  275. # Check if there's a component with the same name already
  276. if existing_item:
  277. # Assign it to the module
  278. existing_item.module = self
  279. update_instances.append(existing_item)
  280. continue
  281. # Only create new components if replication is enabled
  282. if not disable_replication:
  283. create_instances.append(template_instance)
  284. # Set default values for any applicable custom fields
  285. if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
  286. for component in create_instances:
  287. component.custom_field_data = cf_defaults
  288. # Set denormalized references
  289. for component in create_instances:
  290. component._site = self.device.site
  291. component._location = self.device.location
  292. component._rack = self.device.rack
  293. if component_model is not ModuleBay:
  294. component_model.objects.bulk_create(create_instances)
  295. # Emit the post_save signal for each newly created object
  296. for component in create_instances:
  297. post_save.send(
  298. sender=component_model,
  299. instance=component,
  300. created=True,
  301. raw=False,
  302. using='default',
  303. update_fields=None
  304. )
  305. else:
  306. # ModuleBays must be saved individually for MPTT
  307. for instance in create_instances:
  308. instance.save()
  309. update_fields = ['module']
  310. component_model.objects.bulk_update(update_instances, update_fields)
  311. # Emit the post_save signal for each updated object
  312. for component in update_instances:
  313. post_save.send(
  314. sender=component_model,
  315. instance=component,
  316. created=False,
  317. raw=False,
  318. using='default',
  319. update_fields=update_fields
  320. )
  321. # Replicate any front/rear port mappings from the ModuleType
  322. create_port_mappings(self.device, self.module_type, self)
  323. # Interface bridges have to be set after interface instantiation
  324. update_interface_bridges(self.device, self.module_type.interfacetemplates, self)