diff --git a/netbox/circuits/migrations/0058_clear_stale_cable_profile_data.py b/netbox/circuits/migrations/0058_clear_stale_cable_profile_data.py new file mode 100644 index 000000000..517257642 --- /dev/null +++ b/netbox/circuits/migrations/0058_clear_stale_cable_profile_data.py @@ -0,0 +1,30 @@ +from django.db import migrations +from django.db.models import Q + + +def clear_stale_cable_profile_data(apps, schema_editor): + """ + Clear cached cable connector and position data from circuit terminations which no + longer have a cable attached. Earlier versions failed to clear these values when a + profiled cable was deleted, causing subsequent validation to fail. + """ + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + db_alias = schema_editor.connection.alias + + CircuitTermination.objects.using(db_alias).filter( + Q(cable_connector__isnull=False) | Q(cable_positions__isnull=False), + cable__isnull=True, + ).update( + cable_connector=None, + cable_positions=None, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('circuits', '0057_default_ordering_indexes'), + ] + + operations = [ + migrations.RunPython(clear_stale_cable_profile_data, migrations.RunPython.noop), + ] diff --git a/netbox/dcim/migrations/0240_clear_stale_cable_profile_data.py b/netbox/dcim/migrations/0240_clear_stale_cable_profile_data.py new file mode 100644 index 000000000..7fbfc67b9 --- /dev/null +++ b/netbox/dcim/migrations/0240_clear_stale_cable_profile_data.py @@ -0,0 +1,42 @@ +from django.db import migrations +from django.db.models import Q + +CABLED_MODELS = ( + 'ConsolePort', + 'ConsoleServerPort', + 'FrontPort', + 'Interface', + 'PowerFeed', + 'PowerOutlet', + 'PowerPort', + 'RearPort', +) + + +def clear_stale_cable_profile_data(apps, schema_editor): + """ + Clear cached cable connector and position data from endpoints which no longer have + a cable attached. Earlier versions failed to clear these values when a profiled + cable was deleted, causing subsequent validation of the endpoint to fail. + """ + db_alias = schema_editor.connection.alias + + for model_name in CABLED_MODELS: + model = apps.get_model('dcim', model_name) + model.objects.using(db_alias).filter( + Q(cable_connector__isnull=False) | Q(cable_positions__isnull=False), + cable__isnull=True, + ).update( + cable_connector=None, + cable_positions=None, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0239_add_portmapping_objectchange'), + ] + + operations = [ + migrations.RunPython(clear_stale_cable_profile_data, migrations.RunPython.noop), + ] diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index d4fb6f8f6..f7fb9c1b4 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -203,7 +203,12 @@ def nullify_connected_endpoints(instance, **kwargs): Disassociate the Cable from the termination object, and retrace any affected CablePaths. """ model = instance.termination_type.model_class() - model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='') + model.objects.filter(pk=instance.termination_id).update( + cable=None, + cable_end='', + cable_connector=None, + cable_positions=None, + ) # If the parent Cable is being deleted in this same operation, skip the # per-termination retrace; retrace_cable_paths() will retrace each affected diff --git a/netbox/dcim/tests/test_signals.py b/netbox/dcim/tests/test_signals.py index 9edcfbd46..99ceff0f0 100644 --- a/netbox/dcim/tests/test_signals.py +++ b/netbox/dcim/tests/test_signals.py @@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import SimpleTestCase, TestCase from dcim import signals -from dcim.choices import CableEndChoices, LinkStatusChoices +from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices from dcim.models import ( Cable, CablePath, @@ -275,6 +275,36 @@ class CableSignalTestCase(TestCase): self.assertEqual(interface_a.cable_end, '') self.assertEqual(interface_b.cable_end, '') + def test_deleting_profiled_cable_nullifies_endpoints(self): + """ + Deleting a profiled cable must clear the cached connector and position data on both endpoints. + """ + interface_a = Interface.objects.create(device=self.device, name='Interface A') + interface_b = Interface.objects.create(device=self.device, name='Interface B') + cable = Cable( + a_terminations=[interface_a], + b_terminations=[interface_b], + profile=CableProfileChoices.SINGLE_1C1P, + ) + cable.save() + + # Confirm the profile metadata was cached on both endpoints. + interface_a.refresh_from_db() + interface_b.refresh_from_db() + self.assertEqual(interface_a.cable_connector, 1) + self.assertEqual(interface_a.cable_positions, [1]) + self.assertEqual(interface_b.cable_connector, 1) + self.assertEqual(interface_b.cable_positions, [1]) + + cable.delete() + + for interface in (interface_a, interface_b): + interface.refresh_from_db() + self.assertIsNone(interface.cable_id) + self.assertEqual(interface.cable_end, '') + self.assertIsNone(interface.cable_connector) + self.assertIsNone(interface.cable_positions) + def test_deleting_cable_skips_per_termination_retrace(self): """ When a Cable is deleted, nullify_connected_endpoints (post_delete on each