modules.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. from collections.abc import Iterable, Mapping
  2. import jsonschema
  3. import yaml
  4. from django.conf import settings
  5. from django.core.exceptions import ValidationError
  6. from django.db import OperationalError, models, router, transaction
  7. from django.db.models.signals import post_save
  8. from django.utils.translation import gettext_lazy as _
  9. from jsonschema.exceptions import ValidationError as JSONValidationError
  10. from dcim.choices import *
  11. from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
  12. from extras.models import CustomField
  13. from netbox.models import PrimaryModel
  14. from netbox.models.features import ImageAttachmentsMixin
  15. from netbox.models.mixins import WeightMixin
  16. from utilities.conversion import non_negative_constraint
  17. from utilities.data import normalize_update_fields
  18. from utilities.exceptions import AbortRequest
  19. from utilities.fields import ColorField, CounterCacheField
  20. from utilities.jsonschema import validate_schema
  21. from utilities.string import title
  22. from utilities.tracking import TrackingModelMixin
  23. from .device_components import *
  24. from .module_moves import ModuleMovePlan
  25. __all__ = (
  26. 'Module',
  27. 'ModuleBayType',
  28. 'ModuleType',
  29. 'ModuleTypeProfile',
  30. )
  31. class ModuleBayType(PrimaryModel):
  32. """
  33. A type classification for module bays. When bay types are assigned to both a ModuleBay and a
  34. ModuleType, module installation is permitted only if the two sets share at least one common
  35. member (i.e. an empty set on either side means unconstrained).
  36. """
  37. name = models.CharField(
  38. verbose_name=_('name'),
  39. max_length=100,
  40. )
  41. slug = models.SlugField(
  42. verbose_name=_('slug'),
  43. max_length=100,
  44. )
  45. manufacturer = models.ForeignKey(
  46. to='dcim.Manufacturer',
  47. on_delete=models.PROTECT,
  48. related_name='module_bay_types',
  49. blank=True,
  50. null=True,
  51. )
  52. color = ColorField(
  53. verbose_name=_('color'),
  54. blank=True,
  55. )
  56. clone_fields = ('manufacturer', 'color')
  57. class Meta:
  58. ordering = ('manufacturer', 'name')
  59. constraints = (
  60. models.UniqueConstraint(
  61. fields=('manufacturer', 'name'),
  62. name='%(app_label)s_%(class)s_unique_manufacturer_name',
  63. nulls_distinct=False,
  64. ),
  65. models.UniqueConstraint(
  66. fields=('manufacturer', 'slug'),
  67. name='%(app_label)s_%(class)s_unique_manufacturer_slug',
  68. nulls_distinct=False,
  69. ),
  70. )
  71. verbose_name = _('module bay type')
  72. verbose_name_plural = _('module bay types')
  73. def __str__(self):
  74. return self.name
  75. @property
  76. def full_name(self):
  77. if self.manufacturer:
  78. return f'{self.manufacturer} {self.name}'
  79. return self.name
  80. class ModuleTypeProfile(PrimaryModel):
  81. """
  82. A profile which defines the attributes which can be set on one or more ModuleTypes.
  83. """
  84. name = models.CharField(
  85. verbose_name=_('name'),
  86. max_length=100,
  87. unique=True
  88. )
  89. schema = models.JSONField(
  90. blank=True,
  91. null=True,
  92. validators=[validate_schema],
  93. verbose_name=_('schema'),
  94. )
  95. clone_fields = ('schema',)
  96. class Meta:
  97. ordering = ('name',)
  98. verbose_name = _('module type profile')
  99. verbose_name_plural = _('module type profiles')
  100. def __str__(self):
  101. return self.name
  102. class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
  103. """
  104. A ModuleType represents a hardware element that can be installed within a device and which houses additional
  105. components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
  106. DeviceType, each ModuleType can have console, power, interface, pass-through port, and module bay templates assigned
  107. to it. It cannot, however, house device bays.
  108. """
  109. profile = models.ForeignKey(
  110. to='dcim.ModuleTypeProfile',
  111. on_delete=models.PROTECT,
  112. related_name='module_types',
  113. blank=True,
  114. null=True
  115. )
  116. manufacturer = models.ForeignKey(
  117. to='dcim.Manufacturer',
  118. on_delete=models.PROTECT,
  119. related_name='module_types'
  120. )
  121. model = models.CharField(
  122. verbose_name=_('model'),
  123. max_length=100
  124. )
  125. part_number = models.CharField(
  126. verbose_name=_('part number'),
  127. max_length=50,
  128. blank=True,
  129. help_text=_('Discrete part number (optional)')
  130. )
  131. airflow = models.CharField(
  132. verbose_name=_('airflow'),
  133. max_length=50,
  134. choices=ModuleAirflowChoices,
  135. blank=True,
  136. null=True
  137. )
  138. cooling_method = models.CharField(
  139. verbose_name=_('cooling method'),
  140. max_length=50,
  141. choices=CoolingMethodChoices,
  142. blank=True,
  143. null=True
  144. )
  145. end_of_life = models.DateField(
  146. verbose_name=_('end of life'),
  147. blank=True,
  148. null=True,
  149. help_text=_('The date after which this module type is no longer supported by the manufacturer')
  150. )
  151. attribute_data = models.JSONField(
  152. blank=True,
  153. null=True,
  154. verbose_name=_('attributes')
  155. )
  156. module_bay_types = models.ManyToManyField(
  157. to='dcim.ModuleBayType',
  158. related_name='module_types',
  159. blank=True,
  160. verbose_name=_('module bay types'),
  161. help_text=_('Types of module bays this module type can be installed in (empty = unconstrained)'),
  162. )
  163. module_count = CounterCacheField(
  164. to_model='dcim.Module',
  165. to_field='module_type'
  166. )
  167. # Counter fields
  168. console_port_template_count = CounterCacheField(
  169. to_model='dcim.ConsolePortTemplate',
  170. to_field='module_type'
  171. )
  172. console_server_port_template_count = CounterCacheField(
  173. to_model='dcim.ConsoleServerPortTemplate',
  174. to_field='module_type'
  175. )
  176. power_port_template_count = CounterCacheField(
  177. to_model='dcim.PowerPortTemplate',
  178. to_field='module_type'
  179. )
  180. power_outlet_template_count = CounterCacheField(
  181. to_model='dcim.PowerOutletTemplate',
  182. to_field='module_type'
  183. )
  184. cooling_intake_template_count = CounterCacheField(
  185. to_model='dcim.CoolingIntakeTemplate',
  186. to_field='module_type'
  187. )
  188. cooling_outflow_template_count = CounterCacheField(
  189. to_model='dcim.CoolingOutflowTemplate',
  190. to_field='module_type'
  191. )
  192. interface_template_count = CounterCacheField(
  193. to_model='dcim.InterfaceTemplate',
  194. to_field='module_type'
  195. )
  196. front_port_template_count = CounterCacheField(
  197. to_model='dcim.FrontPortTemplate',
  198. to_field='module_type'
  199. )
  200. rear_port_template_count = CounterCacheField(
  201. to_model='dcim.RearPortTemplate',
  202. to_field='module_type'
  203. )
  204. module_bay_template_count = CounterCacheField(
  205. to_model='dcim.ModuleBayTemplate',
  206. to_field='module_type'
  207. )
  208. clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow', 'cooling_method')
  209. prerequisite_models = (
  210. 'dcim.Manufacturer',
  211. )
  212. class Meta:
  213. ordering = ('profile', 'manufacturer', 'model')
  214. constraints = (
  215. models.UniqueConstraint(
  216. fields=('manufacturer', 'model'),
  217. name='%(app_label)s_%(class)s_unique_manufacturer_model'
  218. ),
  219. non_negative_constraint('_abs_weight'),
  220. )
  221. indexes = (
  222. models.Index(fields=('profile', 'manufacturer', 'model')), # Default ordering
  223. )
  224. verbose_name = _('module type')
  225. verbose_name_plural = _('module types')
  226. def __str__(self):
  227. return self.model
  228. @property
  229. def full_name(self):
  230. return f"{self.manufacturer} {self.model}"
  231. @property
  232. def attributes(self):
  233. """
  234. Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile.
  235. """
  236. if not self.attribute_data or self.profile is None or not self.profile.schema:
  237. return {}
  238. attrs = {}
  239. for name, options in self.profile.schema.get('properties', {}).items():
  240. key = options.get('title', title(name))
  241. value = self.attribute_data.get(name)
  242. if isinstance(value, Iterable) and not isinstance(value, (str, bytes, Mapping)):
  243. value = ', '.join(str(v) for v in value)
  244. attrs[key] = value
  245. return dict(sorted(attrs.items()))
  246. def get_incompatible_modules(self):
  247. """
  248. Return a queryset of Module instances of this type that are installed in bays whose
  249. bay type sets are non-empty and share no members with this type's bay type set.
  250. If this type has no bay type constraints, no installation can be incompatible.
  251. """
  252. type_type_pks = list(self.module_bay_types.values_list('pk', flat=True))
  253. if not type_type_pks:
  254. return Module.objects.none()
  255. # Bays that have any compatible type (the intersection is non-empty)
  256. compatible_bay_pks = ModuleBay.objects.filter(
  257. module_bay_types__pk__in=type_type_pks
  258. ).values_list('pk', flat=True)
  259. # Bays with at least one type set (constrained bays); distinct() prevents M2M join duplicates
  260. constrained_bay_pks = ModuleBay.objects.filter(
  261. module_bay_types__isnull=False
  262. ).distinct().values_list('pk', flat=True)
  263. return Module.objects.filter(
  264. module_type=self,
  265. module_bay__pk__in=constrained_bay_pks,
  266. ).exclude(
  267. module_bay__pk__in=compatible_bay_pks,
  268. )
  269. def clean(self):
  270. super().clean()
  271. # Validate any attributes against the assigned profile's schema
  272. if self.profile and self.profile.schema:
  273. try:
  274. jsonschema.validate(self.attribute_data, schema=self.profile.schema)
  275. except JSONValidationError as e:
  276. raise ValidationError(_("Invalid schema: {error}").format(error=e))
  277. else:
  278. self.attribute_data = None
  279. def get_cooling_method_color(self):
  280. return CoolingMethodChoices.colors.get(self.cooling_method)
  281. def to_yaml(self):
  282. data = {
  283. 'profile': self.profile.name if self.profile else None,
  284. 'manufacturer': self.manufacturer.name,
  285. 'model': self.model,
  286. 'part_number': self.part_number,
  287. 'description': self.description,
  288. 'weight': float(self.weight) if self.weight is not None else None,
  289. 'weight_unit': self.weight_unit,
  290. 'airflow': self.airflow,
  291. 'cooling_method': self.cooling_method,
  292. 'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
  293. 'attribute_data': self.attribute_data,
  294. 'comments': self.comments,
  295. 'module_bay_types': [t.name for t in self.module_bay_types.all()],
  296. }
  297. # Component templates
  298. if self.consoleporttemplates.exists():
  299. data['console-ports'] = [
  300. c.to_yaml() for c in self.consoleporttemplates.all()
  301. ]
  302. if self.consoleserverporttemplates.exists():
  303. data['console-server-ports'] = [
  304. c.to_yaml() for c in self.consoleserverporttemplates.all()
  305. ]
  306. if self.powerporttemplates.exists():
  307. data['power-ports'] = [
  308. c.to_yaml() for c in self.powerporttemplates.all()
  309. ]
  310. if self.poweroutlettemplates.exists():
  311. data['power-outlets'] = [
  312. c.to_yaml() for c in self.poweroutlettemplates.all()
  313. ]
  314. if self.coolingintaketemplates.exists():
  315. data['cooling-intakes'] = [
  316. c.to_yaml() for c in self.coolingintaketemplates.all()
  317. ]
  318. if self.coolingoutflowtemplates.exists():
  319. data['cooling-outflows'] = [
  320. c.to_yaml() for c in self.coolingoutflowtemplates.all()
  321. ]
  322. if self.interfacetemplates.exists():
  323. data['interfaces'] = [
  324. c.to_yaml() for c in self.interfacetemplates.all()
  325. ]
  326. if self.frontporttemplates.exists():
  327. data['front-ports'] = [
  328. c.to_yaml() for c in self.frontporttemplates.all()
  329. ]
  330. if self.rearporttemplates.exists():
  331. data['rear-ports'] = [
  332. c.to_yaml() for c in self.rearporttemplates.all()
  333. ]
  334. # Port mappings
  335. port_mapping_data = [
  336. c.to_yaml() for c in self.port_mappings.all()
  337. ]
  338. if port_mapping_data:
  339. data['port-mappings'] = port_mapping_data
  340. return yaml.dump(dict(data), sort_keys=False)
  341. class Module(TrackingModelMixin, PrimaryModel):
  342. """
  343. A Module represents a field-installable component within a Device which may itself hold multiple device components
  344. (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
  345. """
  346. device = models.ForeignKey(
  347. to='dcim.Device',
  348. on_delete=models.CASCADE,
  349. related_name='modules'
  350. )
  351. module_bay = models.OneToOneField(
  352. to='dcim.ModuleBay',
  353. on_delete=models.CASCADE,
  354. related_name='installed_module'
  355. )
  356. module_type = models.ForeignKey(
  357. to='dcim.ModuleType',
  358. on_delete=models.PROTECT,
  359. related_name='instances'
  360. )
  361. status = models.CharField(
  362. verbose_name=_('status'),
  363. max_length=50,
  364. choices=ModuleStatusChoices,
  365. default=ModuleStatusChoices.STATUS_ACTIVE
  366. )
  367. serial = models.CharField(
  368. max_length=50,
  369. blank=True,
  370. verbose_name=_('serial number')
  371. )
  372. asset_tag = models.CharField(
  373. max_length=50,
  374. blank=True,
  375. null=True,
  376. unique=True,
  377. verbose_name=_('asset tag'),
  378. help_text=_('A unique tag used to identify this device')
  379. )
  380. clone_fields = ('device', 'module_type', 'status')
  381. class Meta:
  382. ordering = ('module_bay',)
  383. verbose_name = _('module')
  384. verbose_name_plural = _('modules')
  385. def __str__(self):
  386. return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
  387. def get_status_color(self):
  388. return ModuleStatusChoices.colors.get(self.status)
  389. @property
  390. def is_bay_compatible(self):
  391. """
  392. Return True if this module's type is compatible with its installed bay, or if either has no type constraints.
  393. Returns False when both the bay and module type have non-empty, disjoint bay type sets.
  394. """
  395. if not (self.module_bay_id and self.module_type_id):
  396. return True
  397. # Use .all() so a prefetch cache (populated by the API list queryset) is honoured; .values_list()
  398. # creates a fresh queryset that bypasses the cache and causes N+1 hits in list views.
  399. bay_types = {t.pk for t in self.module_bay.module_bay_types.all()}
  400. type_types = {t.pk for t in self.module_type.module_bay_types.all()}
  401. if bay_types and type_types and not (bay_types & type_types):
  402. return False
  403. return True
  404. def clean(self):
  405. super().clean()
  406. if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
  407. raise ValidationError(
  408. _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
  409. device=self.device
  410. )
  411. )
  412. if not self.is_bay_compatible:
  413. raise ValidationError(
  414. _('Module type {module_type} is not compatible with module bay {module_bay}: '
  415. 'their bay type sets have no common members.').format(
  416. module_type=self.module_type,
  417. module_bay=self.module_bay,
  418. )
  419. )
  420. # Prevent module from being installed in a disabled bay
  421. if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
  422. current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first()
  423. if self.pk is None or current_module_bay_id != self.module_bay_id:
  424. raise ValidationError({
  425. 'module_bay': _("Cannot install a module in a disabled module bay.")
  426. })
  427. # Prevent installation into an occupied module bay
  428. if hasattr(self, 'module_bay') and self.module_bay_id and (
  429. occupant := Module.objects.filter(module_bay_id=self.module_bay_id).exclude(pk=self.pk).first()
  430. ):
  431. raise ValidationError({
  432. 'module_bay': _(
  433. "Module bay {module_bay} is already occupied by module {module}."
  434. ).format(module_bay=self.module_bay, module=occupant)
  435. })
  436. # Validate a requested move (device and/or module bay change) of an existing module
  437. if not self._state.adding and hasattr(self, 'module_bay') and self.module_bay_id and self.device_id:
  438. old = Module.objects.filter(pk=self.pk).first()
  439. if old and (old.device_id != self.device_id or old.module_bay_id != self.module_bay_id):
  440. if old.module_type_id != self.module_type_id:
  441. raise ValidationError({
  442. 'module_type': _(
  443. "Changing a module's type while moving it is not supported. Change the module "
  444. "type and move the module as separate operations."
  445. )
  446. })
  447. try:
  448. ModuleMovePlan.from_module(old_module=old, new_module=self).validate()
  449. except ValueError as e:
  450. raise ValidationError({'module_bay': str(e)}) from e
  451. # Check for recursion
  452. module = self
  453. module_bays = []
  454. modules = []
  455. while module:
  456. module_module_bay = getattr(module, "module_bay", None)
  457. if module.pk in modules or (module_module_bay and module_module_bay.pk in module_bays):
  458. raise ValidationError(_("A module bay cannot belong to a module installed within it."))
  459. modules.append(module.pk)
  460. if module_module_bay:
  461. module_bays.append(module_module_bay.pk)
  462. module = module_module_bay.module if module_module_bay else None
  463. def save(self, *args, **kwargs):
  464. # Normalize before the pk branch so _save_new() and _save_existing() forward a re-iterable value.
  465. update_fields = normalize_update_fields(kwargs)
  466. if self.pk is None:
  467. self._save_new(*args, **kwargs)
  468. return
  469. placement_fields = {'device', 'device_id', 'module_bay', 'module_bay_id'}
  470. if update_fields is not None and placement_fields.isdisjoint(update_fields):
  471. # Placement columns cannot be written by this save, so no move can occur.
  472. super().save(*args, **kwargs)
  473. return
  474. self._save_existing(*args, **kwargs)
  475. def _save_new(self, *args, **kwargs):
  476. super().save(*args, **kwargs)
  477. using = self._state.db
  478. adopt_components = getattr(self, '_adopt_components', False)
  479. disable_replication = getattr(self, '_disable_replication', False)
  480. # We skip adding components if both replication and component adoption is disabled
  481. if disable_replication and not adopt_components:
  482. return
  483. # Iterate all component types
  484. for templates, component_attribute, component_model in [
  485. ("consoleporttemplates", "consoleports", ConsolePort),
  486. ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
  487. ("interfacetemplates", "interfaces", Interface),
  488. ("powerporttemplates", "powerports", PowerPort),
  489. ("poweroutlettemplates", "poweroutlets", PowerOutlet),
  490. ("coolingintaketemplates", "coolingintakes", CoolingIntake),
  491. ("coolingoutflowtemplates", "coolingoutflows", CoolingOutflow),
  492. ("rearporttemplates", "rearports", RearPort),
  493. ("frontporttemplates", "frontports", FrontPort),
  494. ("modulebaytemplates", "modulebays", ModuleBay),
  495. ]:
  496. create_instances = []
  497. update_instances = []
  498. # Prefetch installed components
  499. installed_components = {
  500. component.name: component
  501. for component in getattr(self.device, component_attribute).db_manager(using).filter(
  502. module__isnull=True
  503. )
  504. }
  505. # Get the template for the module type.
  506. for template in getattr(self.module_type, templates).all():
  507. template_instance = template.instantiate(device=self.device, module=self)
  508. template_instance._source_template = template
  509. if adopt_components:
  510. existing_item = installed_components.get(template_instance.name)
  511. # Check if there's a component with the same name already
  512. if existing_item:
  513. # Assign it to the module
  514. existing_item.module = self
  515. update_instances.append(existing_item)
  516. continue
  517. # Only create new components if replication is enabled
  518. if not disable_replication:
  519. create_instances.append(template_instance)
  520. # Set default values for any applicable custom fields
  521. if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
  522. for component in create_instances:
  523. component.custom_field_data = cf_defaults
  524. # Set denormalized references
  525. for component in create_instances:
  526. component._site = self.device.site
  527. component._location = self.device.location
  528. component._rack = self.device.rack
  529. # Bulk-create new instances. ModuleBay is ltree-backed: its parent is set
  530. # in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()),
  531. # and the BEFORE INSERT trigger derives path/sort_path from parent_id per row.
  532. component_model.objects.using(using).bulk_create(create_instances)
  533. # Copy M2M module_bay_types from template to new ModuleBay instances.
  534. if component_model is ModuleBay:
  535. for component in create_instances:
  536. if src := getattr(component, '_source_template', None):
  537. component.module_bay_types.set(src.module_bay_types.db_manager(using).all())
  538. # Emit the post_save signal for each newly created object
  539. for component in create_instances:
  540. post_save.send(
  541. sender=component_model,
  542. instance=component,
  543. created=True,
  544. raw=False,
  545. using=using,
  546. update_fields=None
  547. )
  548. update_fields = ['module']
  549. # ModuleBay.parent is derived from .module in ModuleBay.save(), and the
  550. # path/sort_path trigger only fires on parent_id/name changes. A bare
  551. # bulk_update(['module']) bypasses both, leaving the adopted bay rooted
  552. # at its pre-adoption location. Set parent in lockstep so the BEFORE
  553. # trigger recomputes path/sort_path.
  554. if component_model is ModuleBay:
  555. for instance in update_instances:
  556. instance.parent = self.module_bay
  557. update_fields = ['module', 'parent']
  558. component_model.objects.using(using).bulk_update(
  559. update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
  560. )
  561. # Emit the post_save signal for each updated object
  562. for component in update_instances:
  563. post_save.send(
  564. sender=component_model,
  565. instance=component,
  566. created=False,
  567. raw=False,
  568. using=using,
  569. update_fields=update_fields
  570. )
  571. # Replicate any front/rear port mappings from the ModuleType
  572. create_port_mappings(self.device, self.module_type, self)
  573. # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so that
  574. # channel subinterfaces validate against a populated parent.
  575. update_interface_parents(self.device, self.module_type.interfacetemplates, self)
  576. update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
  577. def _save_existing(self, *args, **kwargs):
  578. try:
  579. with transaction.atomic(using=router.db_for_write(Module)):
  580. # Root row locks first (matches API ETag path); all routing below decides from this locked read
  581. locked_old = Module.objects.select_for_update().only(
  582. 'device', 'module_bay', 'module_type'
  583. ).filter(pk=self.pk).first()
  584. if locked_old is None:
  585. # A new pk, or a row concurrently deleted; create instead.
  586. self._save_new(*args, **kwargs)
  587. return
  588. delta_fields = []
  589. if locked_old.device_id != self.device_id:
  590. delta_fields.append('device')
  591. if locked_old.module_bay_id != self.module_bay_id:
  592. delta_fields.append('module_bay')
  593. if not delta_fields:
  594. super().save(*args, **kwargs)
  595. return
  596. update_fields = kwargs.get('update_fields')
  597. if update_fields is not None:
  598. field_attnames = {'device': 'device_id', 'module_bay': 'module_bay_id'}
  599. listed = {
  600. field for field in delta_fields
  601. if field in update_fields or field_attnames[field] in update_fields
  602. }
  603. if not listed:
  604. # None of the changed placement fields are part of this write, so no move happens.
  605. super().save(*args, **kwargs)
  606. return
  607. if listed != set(delta_fields):
  608. raise AbortRequest(_(
  609. "A module move must include every changed placement field in update_fields: "
  610. "'device' (or 'device_id') and/or 'module_bay' (or 'module_bay_id')."
  611. ))
  612. if locked_old.module_type_id != self.module_type_id:
  613. raise AbortRequest(_(
  614. "Changing a module's type while moving it is not supported. Change the module type and "
  615. "move the module as separate operations."
  616. ))
  617. try:
  618. plan = ModuleMovePlan.from_module(old_module=locked_old, new_module=self)
  619. plan.lock()
  620. except ValueError as e:
  621. raise AbortRequest(str(e)) from e
  622. try:
  623. plan.validate()
  624. except ValidationError as e:
  625. raise AbortRequest(' '.join(e.messages)) from e
  626. super().save(*args, **kwargs)
  627. plan.apply_after_root_save()
  628. except OperationalError as e:
  629. if getattr(e.__cause__, 'sqlstate', None) == '40P01':
  630. raise AbortRequest(_(
  631. "This module or its components are being modified by another request. Please try again."
  632. )) from e
  633. raise