diff --git a/netbox/circuits/migrations/0021_cablepath.py b/netbox/circuits/migrations/0021_cablepath.py new file mode 100644 index 000000000..fabf71798 --- /dev/null +++ b/netbox/circuits/migrations/0021_cablepath.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-10-02 19:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0120_cablepath'), + ('circuits', '0020_custom_field_data'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + ] diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py index f3448e747..0d2199e31 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2020-10-02 15:49 +# Generated by Django 3.1 on 2020-10-02 19:43 import dcim.fields from django.db import migrations, models @@ -28,4 +28,34 @@ class Migration(migrations.Migration): 'unique_together': {('origin_type', 'origin_id')}, }, ), + migrations.AddField( + model_name='consoleport', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='consoleserverport', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='interface', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='powerfeed', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='poweroutlet', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='powerport', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b714c662a..65032f529 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -126,38 +126,30 @@ class CableTermination(models.Model): class PathEndpoint(models.Model): """ - Any object which may serve as either endpoint of a CablePath. + Any object which may serve as the originating endpoint of a CablePath. """ - _paths = GenericRelation( + _path = models.ForeignKey( to='dcim.CablePath', - content_type_field='origin_type', - object_id_field='origin_id', - related_query_name='%(class)s' + on_delete=models.SET_NULL, + null=True, + blank=True ) class Meta: abstract = True def trace(self): - if self.path is None: + if self._path is None: return [] # Construct the complete path - path = [self, *[path_node_to_object(obj) for obj in self.path.path], self.path.destination] - assert not len(path) % 3, f"Invalid path length for CablePath #{self.pk}: {len(self.path)} elements in path" + path = [self, *[path_node_to_object(obj) for obj in self._path.path], self._path.destination] + assert not len(path) % 3,\ + f"Invalid path length for CablePath #{self.pk}: {len(self._path.path)} elements in path" # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) - @property - def path(self): - """ - Return the _complete_ CablePath associated with this origin point, if any. - """ - if not hasattr(self, '_path'): - self._path = self._paths.filter(destination_id__isnull=False).first() - return self._path - # # Console ports diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 52627dc7d..ea7332b3d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1204,6 +1204,13 @@ class CablePath(models.Model): path = ', '.join([str(path_node_to_object(node)) for node in self.path]) return f"Path #{self.pk}: {self.origin} to {self.destination} via ({path})" + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # Record a direct reference to this CablePath on its originating object + model = self.origin._meta.model + model.objects.filter(pk=self.origin.pk).update(_path=self.pk) + # # Virtual chassis diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9b0493e34..13653c7d4 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,7 +1,7 @@ import logging from django.contrib.contenttypes.models import ContentType -from django.db.models.signals import post_save, pre_delete +from django.db.models.signals import post_save, post_delete, pre_delete from django.db import transaction from django.dispatch import receiver @@ -91,7 +91,7 @@ def update_connected_endpoints(instance, created, **kwargs): rebuild_paths(instance) -@receiver(pre_delete, sender=Cable) +@receiver(post_delete, sender=Cable) def nullify_connected_endpoints(instance, **kwargs): """ When a Cable is deleted, check for and update its two connected endpoints @@ -108,18 +108,15 @@ def nullify_connected_endpoints(instance, **kwargs): instance.termination_b.cable = None instance.termination_b.save() - # Delete any dependent cable paths - cable_paths = CablePath.objects.filter(path__contains=[object_to_path_node(instance)]) - retrace_queue = [cp.origin for cp in cable_paths] - deleted, _ = cable_paths.delete() - logger.info(f'Deleted {deleted} cable paths') - - # Retrace cable paths from the origins of deleted paths - for origin in retrace_queue: - # Delete and recreate all CablePaths for this origin point - # TODO: We can probably be smarter about skipping unchanged paths - CablePath.objects.filter( - origin_type=ContentType.objects.get_for_model(origin), - origin_id=origin.pk - ).delete() - create_cablepath(origin) + # Delete and retrace any dependent cable paths + for cablepath in CablePath.objects.filter(path__contains=[object_to_path_node(instance)]): + path, destination, is_connected = trace_path(cablepath.origin) + if path: + CablePath.objects.filter(pk=cablepath.pk).update( + path=path, + destination_type=ContentType.objects.get_for_model(destination) if destination else None, + destination_id=destination.pk if destination else None, + is_connected=is_connected + ) + else: + cablepath.delete() diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 18c15fd16..52b37e3bd 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -132,6 +132,8 @@ class CablePathTestCase(TestCase): :param path: Sequence of objects comprising the intermediate path (optional) :param is_connected: Boolean indicating whether the end-to-end path is complete and active (optional) :param msg: Custom failure message (optional) + + :return: The matching CablePath (if any) """ kwargs = { 'origin_type': ContentType.objects.get_for_model(origin), @@ -152,7 +154,34 @@ class CablePathTestCase(TestCase): msg = f"Missing path from {origin} to {destination}" else: msg = f"Missing partial path originating from {origin}" - self.assertEqual(CablePath.objects.filter(**kwargs).count(), 1, msg=msg) + + cablepath = CablePath.objects.filter(**kwargs).first() + self.assertIsNotNone(cablepath, msg=msg) + + return cablepath + + def assertPathIsSet(self, origin, cablepath, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param cablepath: The CablePath instance originating from this endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}" + self.assertEqual(origin._path_id, cablepath.pk, msg=msg) + + def assertPathIsNotSet(self, origin, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!" + self.assertIsNone(origin._path_id, msg=msg) def test_101_interface_to_interface(self): """ @@ -161,19 +190,23 @@ class CablePathTestCase(TestCase): # Create cable 1 cable1 = Cable(termination_a=self.interface1, termination_b=self.interface2) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=self.interface2, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.interface2, destination=self.interface1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsSet(self.interface2, path2) # Delete cable 1 cable1.delete() @@ -181,26 +214,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_103_consoleport_to_consoleserverport(self): + def test_102_consoleport_to_consoleserverport(self): """ [CP1] --C1-- [CSP1] """ # Create cable 1 cable1 = Cable(termination_a=self.consoleport1, termination_b=self.consoleserverport1) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.consoleport1, destination=self.consoleserverport1, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.consoleserverport1, destination=self.consoleport1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.consoleport1.refresh_from_db() + self.consoleserverport1.refresh_from_db() + self.assertPathIsSet(self.consoleport1, path1) + self.assertPathIsSet(self.consoleserverport1, path2) # Delete cable 1 cable1.delete() @@ -208,26 +245,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_104_powerport_to_poweroutlet(self): + def test_103_powerport_to_poweroutlet(self): """ [PP1] --C1-- [PO1] """ # Create cable 1 cable1 = Cable(termination_a=self.powerport1, termination_b=self.poweroutlet1) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.powerport1, destination=self.poweroutlet1, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.poweroutlet1, destination=self.powerport1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.powerport1.refresh_from_db() + self.poweroutlet1.refresh_from_db() + self.assertPathIsSet(self.powerport1, path1) + self.assertPathIsSet(self.poweroutlet1, path2) # Delete cable 1 cable1.delete() @@ -235,26 +276,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_105_powerport_to_powerfeed(self): + def test_104_powerport_to_powerfeed(self): """ [PP1] --C1-- [PF1] """ # Create cable 1 cable1 = Cable(termination_a=self.powerport1, termination_b=self.powerfeed1) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.powerport1, destination=self.powerfeed1, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.powerfeed1, destination=self.powerport1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.powerport1.refresh_from_db() + self.powerfeed1.refresh_from_db() + self.assertPathIsSet(self.powerport1, path1) + self.assertPathIsSet(self.powerfeed1, path2) # Delete cable 1 cable1.delete() @@ -262,26 +307,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_106_interface_to_circuittermination(self): + def test_105_interface_to_circuittermination(self): """ [PP1] --C1-- [CT1A] """ # Create cable 1 cable1 = Cable(termination_a=self.interface1, termination_b=self.circuittermination1_A) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=self.circuittermination1_A, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.circuittermination1_A, destination=self.interface1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.interface1.refresh_from_db() + self.circuittermination1_A.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsSet(self.circuittermination1_A, path2) # Delete cable 1 cable1.delete() @@ -293,6 +342,9 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + # Create cable 1 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save() @@ -323,19 +375,28 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=None, path=(cable1, self.front_port5_1, self.rear_port5), is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsNotSet(self.interface2) def test_202_multiple_paths_via_pass_through(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-2 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() @@ -377,7 +438,7 @@ class CablePathTestCase(TestCase): cable4.save() cable5 = Cable(termination_a=self.front_port2_2, termination_b=self.interface4) cable5.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=self.interface3, path=( @@ -386,7 +447,7 @@ class CablePathTestCase(TestCase): ), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.interface2, destination=self.interface4, path=( @@ -395,7 +456,7 @@ class CablePathTestCase(TestCase): ), is_connected=True ) - self.assertPathExists( + path3 = self.assertPathExists( origin=self.interface3, destination=self.interface1, path=( @@ -404,7 +465,7 @@ class CablePathTestCase(TestCase): ), is_connected=True ) - self.assertPathExists( + path4 = self.assertPathExists( origin=self.interface4, destination=self.interface2, path=( @@ -421,12 +482,25 @@ class CablePathTestCase(TestCase): # Check for four partial paths; one from each interface self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsSet(self.interface2, path2) + self.assertPathIsSet(self.interface3, path3) + self.assertPathIsSet(self.interface4, path4) def test_203_multiple_paths_via_nested_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2:1] [RP2] --C4-- [RP3] [FP3:1] --C5-- [RP4] [FP4:1] --C6-- [IF3] [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-2, 6-7 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() @@ -502,6 +576,11 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-3, 6-8 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() @@ -576,6 +655,11 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP5] [RP5] --C4-- [RP2] [FP2:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-2, 5-6 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) # IF1 -> FP1:1 cable1.save() @@ -641,6 +725,9 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + # Create cable 2 cable2 = Cable(termination_a=self.rear_port5, termination_b=self.rear_port6) cable2.save() @@ -684,6 +771,9 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + # Create cables 1 and 2 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save()