diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 857945d6c..f52367c6d 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -326,9 +326,20 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel): def save(self, *args, **kwargs): is_new = self.pk is None + old_module_bay_id = None + + if not is_new: + old_module_bay_id = Module.objects.filter(pk=self.pk).values_list( + 'module_bay_id', flat=True + ).first() super().save(*args, **kwargs) + if old_module_bay_id is not None and old_module_bay_id != self.module_bay_id: + for child_bay in self.modulebays.select_related('module__module_bay'): + child_bay.snapshot() + child_bay.save() + adopt_components = getattr(self, '_adopt_components', False) disable_replication = getattr(self, '_disable_replication', False) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 260615b4e..b266e9cde 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1188,6 +1188,103 @@ class ModuleBayTestCase(TestCase): self.assertEqual(movable_bay.parent_id, host_bay.pk) self.assertEqual(movable_bay.tree_id, host_bay.tree_id) + @tag('regression') # #22251 + def test_moving_module_reparents_child_module_bays(self): + """ + When a module is moved to a different module bay, each child ModuleBay + (a bay that belongs to the module) must have its MPTT parent updated + to the new host bay. Without the fix the children stay parented to the + old bay even though Module.module_bay_id has changed. + """ + device_type = DeviceType.objects.first() + device_role = DeviceRole.objects.first() + site = Site.objects.first() + device = Device.objects.create( + name='Move Module Device', + device_type=device_type, + role=device_role, + site=site, + ) + bay_a = ModuleBay.objects.create(device=device, name='Bay A') + bay_b = ModuleBay.objects.create(device=device, name='Bay B') + + manufacturer = Manufacturer.objects.first() + module_type = ModuleType.objects.create( + manufacturer=manufacturer, model='Move Module Type' + ) + module = Module.objects.create( + device=device, module_bay=bay_a, module_type=module_type + ) + + child_1 = ModuleBay.objects.create(device=device, module=module, name='Child Bay 1') + child_2 = ModuleBay.objects.create(device=device, module=module, name='Child Bay 2') + self.assertEqual(child_1.parent_id, bay_a.pk) + self.assertEqual(child_2.parent_id, bay_a.pk) + + # Move the module to bay_b. + module.module_bay = bay_b + module.save() + + child_1.refresh_from_db() + child_2.refresh_from_db() + self.assertEqual(child_1.parent_id, bay_b.pk) + self.assertEqual(child_2.parent_id, bay_b.pk) + # Children must share the same MPTT tree as their new parent. + bay_b.refresh_from_db() + self.assertEqual(child_1.tree_id, bay_b.tree_id) + self.assertEqual(child_2.tree_id, bay_b.tree_id) + + @tag('regression') # #22251 + def test_moving_module_reparents_grandchild_module_bays(self): + """ + When a module is moved, grandchild ModuleBays (bays inside a module + that is itself installed inside a child bay of the moved module) must + also land in the new MPTT tree. MPTT moves subtrees atomically, so + calling save() only on direct children is sufficient — this test + documents and preserves that invariant for future tree-backend changes. + """ + device_type = DeviceType.objects.first() + device_role = DeviceRole.objects.first() + site = Site.objects.first() + device = Device.objects.create( + name='Grandchild Move Device', + device_type=device_type, + role=device_role, + site=site, + ) + bay_a = ModuleBay.objects.create(device=device, name='Bay A') + bay_b = ModuleBay.objects.create(device=device, name='Bay B') + + manufacturer = Manufacturer.objects.first() + module_type = ModuleType.objects.create( + manufacturer=manufacturer, model='Grandchild Move Type' + ) + # Depth-1: module installed in bay_a, with one child bay. + module_1 = Module.objects.create(device=device, module_bay=bay_a, module_type=module_type) + child_bay = ModuleBay.objects.create(device=device, module=module_1, name='Child Bay') + + # Depth-2: module installed in child_bay, with one grandchild bay. + module_2 = Module.objects.create(device=device, module_bay=child_bay, module_type=module_type) + grandchild_bay = ModuleBay.objects.create(device=device, module=module_2, name='Grandchild Bay') + + self.assertEqual(child_bay.parent_id, bay_a.pk) + self.assertEqual(grandchild_bay.parent_id, child_bay.pk) + self.assertEqual(grandchild_bay.tree_id, bay_a.tree_id) + + # Move the top-level module to bay_b. + module_1.module_bay = bay_b + module_1.save() + + child_bay.refresh_from_db() + grandchild_bay.refresh_from_db() + bay_b.refresh_from_db() + + self.assertEqual(child_bay.parent_id, bay_b.pk) + self.assertEqual(child_bay.tree_id, bay_b.tree_id) + # Grandchild's direct parent (child_bay) is unchanged; only tree placement moves. + self.assertEqual(grandchild_bay.parent_id, child_bay.pk) + self.assertEqual(grandchild_bay.tree_id, bay_b.tree_id) + def test_single_module_token(self): device_type = DeviceType.objects.first() device_role = DeviceRole.objects.first()