Compare commits

...

4 Commits

Author SHA1 Message Date
Martin Hauser
b97f6fa588 Merge 60fce84c96 into 598f8d034d 2025-12-12 22:33:02 +01:00
Jason Novinger
598f8d034d Fixes #20912: Clear ModuleBay parent when module assignment removed (#20974)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-12-12 13:31:59 -08:00
Arthur Hanson
ec13a79907 Fixes #20875: Fix updating of denormalized fields for component models (#20956) 2025-12-12 13:29:34 -06:00
Martin Hauser
60fce84c96 feat(ipam): Normalize numeric ranges in API output
Adds logic to handle numeric range fields in API responses by
converting them into inclusive `[low, high]` pairs for consistent
behavior. Updates test cases with `vid_ranges` fields to reflect the
changes.

Closes #20491
2025-12-10 21:11:23 +01:00
7 changed files with 58 additions and 3 deletions

View File

@@ -1222,6 +1222,8 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
def save(self, *args, **kwargs):
if self.module:
self.parent = self.module.module_bay
else:
self.parent = None
super().save(*args, **kwargs)

View File

@@ -957,6 +957,11 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
# Set denormalized references
for component in components:
component._site = self.site
component._location = self.location
component._rack = self.rack
components = model.objects.bulk_create(components)
# Prefetch related objects to minimize queries needed during post_save
prefetch_fields = get_prefetchable_fields(model)

View File

@@ -315,6 +315,12 @@ class Module(PrimaryModel, ConfigContextModel):
for component in create_instances:
component.custom_field_data = cf_defaults
# Set denormalized references
for component in create_instances:
component._site = self.device.site
component._location = self.device.location
component._rack = self.device.rack
if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object

View File

@@ -44,6 +44,9 @@ def handle_location_site_change(instance, created, **kwargs):
Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
# Update component models for devices in these locations
for model in COMPONENT_MODELS:
model.objects.filter(device__location__in=locations).update(_site=instance.site)
@receiver(post_save, sender=Rack)
@@ -53,6 +56,12 @@ def handle_rack_site_change(instance, created, **kwargs):
"""
if not created:
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
# Update component models for devices in this rack
for model in COMPONENT_MODELS:
model.objects.filter(device__rack=instance).update(
_site=instance.site,
_location=instance.location,
)
@receiver(post_save, sender=Device)

View File

@@ -841,6 +841,32 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='SFP A-21')
self.assertEqual(nested_bay.label, 'A-21')
@tag('regression') # #20912
def test_module_bay_parent_cleared_when_module_removed(self):
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type')
bay1 = ModuleBay.objects.create(device=device, name='Test Bay 1')
bay2 = ModuleBay.objects.create(device=device, name='Test Bay 2')
# Install a module in bay1
module1 = Module.objects.create(device=device, module_bay=bay1, module_type=module_type)
# Assign bay2 to module1 and verify parent is now set to bay1 (module1's bay)
bay2.module = module1
bay2.save()
bay2.refresh_from_db()
self.assertEqual(bay2.parent, bay1)
self.assertEqual(bay2.module, module1)
# Clear the module assignment (return bay2 to device level) Verify parent is cleared
bay2.module = None
bay2.save()
bay2.refresh_from_db()
self.assertIsNone(bay2.parent)
self.assertIsNone(bay2.module)
class CableTestCase(TestCase):

View File

@@ -1071,14 +1071,17 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
{
'name': 'VLAN Group 4',
'slug': 'vlan-group-4',
'vid_ranges': [[1, 4094]]
},
{
'name': 'VLAN Group 5',
'slug': 'vlan-group-5',
'vid_ranges': [[1, 4094]]
},
{
'name': 'VLAN Group 6',
'slug': 'vlan-group-6',
'vid_ranges': [[1, 4094]]
},
]
bulk_update_data = {

View File

@@ -141,8 +141,8 @@ class ModelTestCase(TestCase):
elif value and type(field) is GenericForeignKey:
model_dict[key] = value.pk
# Handle API output
elif api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) in (ContentType, ObjectType):
object_type = ObjectType.objects.get(pk=value)
@@ -152,9 +152,13 @@ class ModelTestCase(TestCase):
elif type(value) is IPNetwork:
model_dict[key] = str(value)
else:
field = instance._meta.get_field(key)
# Normalize arrays of numeric ranges (e.g. VLAN IDs or port ranges).
# DB uses canonical half-open [lo, hi) via NumericRange; API uses inclusive [lo, hi].
# Convert to inclusive pairs for stable API comparisons.
elif type(field) is ArrayField and issubclass(type(field.base_field), RangeField):
model_dict[key] = [[r.lower, r.upper - 1] for r in value]
else:
# Convert ArrayFields to CSV strings
if type(field) is ArrayField:
if getattr(field.base_field, 'choices', None):