diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b3bda01cd..c82c4aff2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -546,7 +546,7 @@ class ModuleViewSet(NetBoxModelViewSet): class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsolePort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet @@ -555,7 +555,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -564,7 +564,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet @@ -573,7 +573,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerOutlet.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet @@ -582,7 +582,7 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable', '_link_peer', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer @@ -685,7 +685,7 @@ class PowerPanelViewSet(NetBoxModelViewSet): class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( - 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' + 'power_panel', 'rack', '_path', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.PowerFeedSerializer filterset_class = filtersets.PowerFeedFilterSet diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index d3afe5c08..5ec88f89a 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -10,6 +10,7 @@ from .lookups import PathContains __all__ = ( 'ASNField', 'MACAddressField', + 'MultiNodePathField', 'PathField', 'WWNField', ) @@ -104,4 +105,16 @@ class PathField(ArrayField): super().__init__(**kwargs) +class MultiNodePathField(ArrayField): + """ + A two-dimensional ArrayField which represents a path, with one or more nodes at each hop. Each node is + identified by a (type, ID) tuple. + """ + def __init__(self, **kwargs): + kwargs['base_field'] = ArrayField( + base_field=models.CharField(max_length=40) + ) + super().__init__(**kwargs) + + PathField.register_lookup(PathContains) diff --git a/netbox/dcim/migrations/0157_cablepath.py b/netbox/dcim/migrations/0157_cablepath.py new file mode 100644 index 000000000..0ff773cba --- /dev/null +++ b/netbox/dcim/migrations/0157_cablepath.py @@ -0,0 +1,29 @@ +import dcim.fields +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0156_cable_remove_terminations'), + ] + + operations = [ + migrations.RenameField( + model_name='cablepath', + old_name='path', + new_name='_nodes', + ), + migrations.AddField( + model_name='cablepath', + name='path', + field=dcim.fields.MultiNodePathField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=40), size=None), default=[], size=None), + preserve_default=False, + ), + migrations.AddField( + model_name='cablepath', + name='is_complete', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/dcim/migrations/0158_cablepath_populate_path.py b/netbox/dcim/migrations/0158_cablepath_populate_path.py new file mode 100644 index 000000000..972c70143 --- /dev/null +++ b/netbox/dcim/migrations/0158_cablepath_populate_path.py @@ -0,0 +1,50 @@ +from django.db import migrations + +from dcim.utils import compile_path_node + + +def populate_cable_paths(apps, schema_editor): + """ + Replicate terminations from the Cable model into CableTermination instances. + """ + CablePath = apps.get_model('dcim', 'CablePath') + + # Construct the new two-dimensional path, and add the origin & destination objects to the nodes list + cable_paths = [] + for cablepath in CablePath.objects.all(): + + # Origin + origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id) + cablepath.path.append([origin]) + cablepath._nodes.insert(0, origin) + + # Transit nodes + cablepath.path.extend([ + [node] for node in cablepath._nodes[1:] + ]) + + # Destination + if cablepath.destination_id: + destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id) + cablepath.path.append([destination]) + cablepath._nodes.append(destination) + cablepath.is_complete = True + + cable_paths.append(cablepath) + + # Bulk create the termination objects + CablePath.objects.bulk_update(cable_paths, fields=('path', 'is_complete'), batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0157_cablepath'), + ] + + operations = [ + migrations.RunPython( + code=populate_cable_paths, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0159_cablepath_remove_origin_destination.py b/netbox/dcim/migrations/0159_cablepath_remove_origin_destination.py new file mode 100644 index 000000000..12d585bf0 --- /dev/null +++ b/netbox/dcim/migrations/0159_cablepath_remove_origin_destination.py @@ -0,0 +1,33 @@ +# Generated by Django 4.0.4 on 2022-05-03 14:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0158_cablepath_populate_path'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='cablepath', + unique_together=set(), + ), + migrations.RemoveField( + model_name='cablepath', + name='destination_id', + ), + migrations.RemoveField( + model_name='cablepath', + name='destination_type', + ), + migrations.RemoveField( + model_name='cablepath', + name='origin_id', + ), + migrations.RemoveField( + model_name='cablepath', + name='origin_type', + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 5eb48fbf2..efe564e12 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -9,8 +9,8 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from dcim.fields import PathField -from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object +from dcim.fields import MultiNodePathField, PathField +from dcim.utils import decompile_path_node, flatten_path, object_to_path_node, path_node_to_object from netbox.models import NetBoxModel from utilities.fields import ColorField from utilities.utils import to_meters @@ -276,57 +276,39 @@ class CablePath(models.Model): `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of "connected". """ - origin_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+' - ) - origin_id = models.PositiveBigIntegerField() - origin = GenericForeignKey( - ct_field='origin_type', - fk_field='origin_id' - ) - destination_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - destination_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - destination = GenericForeignKey( - ct_field='destination_type', - fk_field='destination_id' - ) - path = PathField() + path = MultiNodePathField() is_active = models.BooleanField( default=False ) + is_complete = models.BooleanField( + default=False + ) is_split = models.BooleanField( default=False ) + _nodes = PathField() class Meta: - unique_together = ('origin_type', 'origin_id') + pass def __str__(self): status = ' (active)' if self.is_active else ' (split)' if self.is_split else '' - return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}" + return f"Path #{self.pk}: {len(self.path)} nodes{status}" def save(self, *args, **kwargs): super().save(*args, **kwargs) + # Save the flattened nodes list + self._nodes = flatten_path(self.path) + + # TODO # 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) + # model = self.origin._meta.model + # model.objects.filter(pk=self.origin.pk).update(_path=self.pk) @property def segment_count(self): - total_length = 1 + len(self.path) + (1 if self.destination else 0) - return int(total_length / 3) + return int(len(self.path) / 3) @classmethod def from_origin(cls, origin): @@ -421,7 +403,7 @@ class CablePath(models.Model): """ # Compile a list of IDs to prefetch for each type of model in the path to_prefetch = defaultdict(list) - for node in self.path: + for node in self._nodes: ct_id, object_id = decompile_path_node(node) to_prefetch[ct_id].append(object_id) @@ -438,18 +420,30 @@ class CablePath(models.Model): # Replicate the path using the prefetched objects. path = [] - for node in self.path: - ct_id, object_id = decompile_path_node(node) - path.append(prefetched[ct_id][object_id]) + for step in self.path: + nodes = [] + for node in step: + ct_id, object_id = decompile_path_node(node) + nodes.append(prefetched[ct_id][object_id]) + path.append(nodes) return path + def get_destination(self): + if not self.is_complete: + return None + return [ + path_node_to_object(node) for node in self.path[-1] + ] + @property - def last_node(self): + def last_nodes(self): """ Return either the destination or the last node within the path. """ - return self.destination or path_node_to_object(self.path[-1]) + return [ + path_node_to_object(node) for node in self.path[-1] + ] def get_cable_ids(self): """ @@ -458,7 +452,7 @@ class CablePath(models.Model): cable_ct = ContentType.objects.get_for_model(Cable).pk cable_ids = [] - for node in self.path: + for node in self._nodes: ct, id = decompile_path_node(node) if ct == cable_ct: cable_ids.append(id) @@ -481,6 +475,6 @@ class CablePath(models.Model): """ Return all available next segments in a split cable path. """ - rearport = path_node_to_object(self.path[-1]) + rearport = path_node_to_object(self._nodes[-1]) return FrontPort.objects.filter(rear_port=rearport) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d2e4a3c88..ec82e6606 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -229,7 +229,7 @@ class PathEndpoint(models.Model): Caching accessor for the attached CablePath's destination (if any) """ if not hasattr(self, '_connected_endpoint'): - self._connected_endpoint = self._path.destination if self._path else None + self._connected_endpoint = self._path.get_destination() return self._connected_endpoint diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 5a5cedcd5..5adf83869 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -133,7 +133,7 @@ def nullify_connected_endpoints(instance, **kwargs): cp = CablePath.from_origin(cablepath.origin) if cp: CablePath.objects.filter(pk=cablepath.pk).update( - path=cp.path, + _nodes=cp._nodes, destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None, destination_id=cp.destination.pk if cp.destination else None, is_active=cp.is_active, diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index ec3a44603..28199aa97 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -29,6 +29,16 @@ def path_node_to_object(repr): return ct.model_class().objects.get(pk=object_id) +def flatten_path(path): + """ + Flatten a two-dimensional array (list of lists) into a flat list. + """ + ret = [] + for step in path: + ret.extend(step) + return ret + + def create_cablepath(node): """ Create CablePaths for all paths originating from the specified node. diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3ea7ecc06..5824e8186 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1711,7 +1711,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView): def get_extra_context(self, request, instance): interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( - '_path__destination' + '_path' ).exclude( type__in=NONCONNECTABLE_IFACE_TYPES ) diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index f159ee637..5528faec2 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -37,14 +37,13 @@ class HomeView(View): return redirect("login") connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False + _path__is_active=True ) connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False + _path__is_active=True ) connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False, - pk__lt=F('_path__destination_id') + _path__is_active=True ) def build_stats():