diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 3acd0d4a1..23b9b7fd5 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,3 +1,5 @@ +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.validators import ArrayMaxLengthValidator from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models @@ -50,3 +52,12 @@ class MACAddressField(models.Field): if not value: return None return str(self.to_python(value)) + + +class PathField(ArrayField): + """ + An ArrayField which holds a set of objects, each identified by a (type, ID) tuple. + """ + def __init__(self, **kwargs): + kwargs['base_field'] = models.CharField(max_length=40) + super().__init__(**kwargs) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py new file mode 100644 index 000000000..903a6feac --- /dev/null +++ b/netbox/dcim/managers.py @@ -0,0 +1,8 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Manager + + +class CablePathManager(Manager): + + def create_for_endpoint(self, endpoint): + ct = ContentType.objects.get_for_model(endpoint) diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py new file mode 100644 index 000000000..4e36c31d0 --- /dev/null +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1 on 2020-09-30 18:09 + +import dcim.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0119_inventoryitem_mptt_rebuild'), + ] + + operations = [ + migrations.CreateModel( + name='CablePath', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('origin_id', models.PositiveIntegerField()), + ('destination_id', models.PositiveIntegerField(blank=True, null=True)), + ('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)), + ('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ], + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index e50fa2eda..fdd4d1bf5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -8,6 +8,7 @@ from .sites import * __all__ = ( 'BaseInterface', 'Cable', + 'CablePath', 'CableTermination', 'ConsolePort', 'ConsolePortTemplate', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 2cc4c8b60..12bb224fd 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,6 +1,7 @@ import logging from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -32,6 +33,7 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'PathEndpoint', 'PowerOutlet', 'PowerPort', 'RearPort', @@ -250,12 +252,23 @@ class CableTermination(models.Model): return endpoints +class PathEndpoint: + + def get_connections(self): + from dcim.models import CablePath + return CablePath.objects.filter( + origin_type=ContentType.objects.get_for_model(self), + origin_id=self.pk, + destination_id__isnull=False + ) + + # # Console ports # @extras_features('export_templates', 'webhooks') -class ConsolePort(CableTermination, ComponentModel): +class ConsolePort(CableTermination, PathEndpoint, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -303,7 +316,7 @@ class ConsolePort(CableTermination, ComponentModel): # @extras_features('webhooks') -class ConsoleServerPort(CableTermination, ComponentModel): +class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -344,7 +357,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): # @extras_features('export_templates', 'webhooks') -class PowerPort(CableTermination, ComponentModel): +class PowerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -493,7 +506,7 @@ class PowerPort(CableTermination, ComponentModel): # @extras_features('webhooks') -class PowerOutlet(CableTermination, ComponentModel): +class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -585,7 +598,7 @@ class BaseInterface(models.Model): @extras_features('export_templates', 'webhooks') -class Interface(CableTermination, ComponentModel, BaseInterface): +class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 463b1a3e3..162dcb831 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -14,6 +14,9 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * +from dcim.fields import PathField +from dcim.managers import CablePathManager +from dcim.utils import path_node_to_object from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.choices import ColorChoices @@ -25,6 +28,7 @@ from .device_components import * __all__ = ( 'Cable', + 'CablePath', 'Device', 'DeviceRole', 'DeviceType', @@ -1154,6 +1158,44 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] +class CablePath(models.Model): + """ + An array of objects conveying the end-to-end path of one or more Cables. + """ + origin_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+' + ) + origin_id = models.PositiveIntegerField() + 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.PositiveIntegerField( + blank=True, + null=True + ) + destination = GenericForeignKey( + ct_field='destination_type', + fk_field='destination_id' + ) + path = PathField() + + objects = CablePathManager() + + def __str__(self): + 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})" + + # # Virtual chassis # diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 172c366b5..4e6cadb28 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,10 +1,34 @@ import logging +from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, pre_delete +from django.db import transaction from django.dispatch import receiver -from .choices import CableStatusChoices -from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis +from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis +from .utils import object_to_path_node, trace_paths + + +def create_cablepaths(node): + """ + Create CablePaths for all paths originating from the specified node. + """ + for path, destination in trace_paths(node): + cp = CablePath(origin=node, path=path, destination=destination) + cp.save() + + +def rebuild_paths(obj): + """ + Rebuild all CablePaths which traverse the specified node + """ + node = object_to_path_node(obj) + cable_paths = CablePath.objects.filter(path__contains=[node]) + + with transaction.atomic(): + for cp in cable_paths: + cp.delete() + create_cablepaths(cp.origin) @receiver(post_save, sender=VirtualChassis) @@ -32,7 +56,7 @@ def clear_virtualchassis_members(instance, **kwargs): @receiver(post_save, sender=Cable) -def update_connected_endpoints(instance, **kwargs): +def update_connected_endpoints(instance, created, **kwargs): """ When a Cable is saved, check for and update its two connected endpoints """ @@ -40,38 +64,25 @@ def update_connected_endpoints(instance, **kwargs): # Cache the Cable on its two termination points if instance.termination_a.cable != instance: - logger.debug("Updating termination A for cable {}".format(instance)) + logger.debug(f"Updating termination A for cable {instance}") instance.termination_a.cable = instance instance.termination_a.save() if instance.termination_b.cable != instance: - logger.debug("Updating termination B for cable {}".format(instance)) + logger.debug(f"Updating termination B for cable {instance}") instance.termination_b.cable = instance instance.termination_b.save() - # Update any endpoints for this Cable. - endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() - for endpoint in endpoints: - path, split_ends, position_stack = endpoint.trace() - # Determine overall path status (connected or planned) - path_status = True - for segment in path: - if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED: - path_status = False - break - - endpoint_a = path[0][0] - endpoint_b = path[-1][2] if not split_ends and not position_stack else None - - # Patch panel ports are not connected endpoints, all other cable terminations are - if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \ - isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)): - logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) - endpoint_a.connected_endpoint = endpoint_b - endpoint_a.connection_status = path_status - endpoint_a.save() - endpoint_b.connected_endpoint = endpoint_a - endpoint_b.connection_status = path_status - endpoint_b.save() + # Create/update cable paths + if created: + for termination in (instance.termination_a, instance.termination_b): + if isinstance(termination, PathEndpoint): + create_cablepaths(termination) + else: + rebuild_paths(termination) + else: + # We currently don't support modifying either termination of an existing Cable. This + # may change in the future. + pass @receiver(pre_delete, sender=Cable) @@ -81,22 +92,28 @@ def nullify_connected_endpoints(instance, **kwargs): """ logger = logging.getLogger('netbox.dcim.cable') - endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() - # Disassociate the Cable from its termination points if instance.termination_a is not None: - logger.debug("Nullifying termination A for cable {}".format(instance)) + logger.debug(f"Nullifying termination A for cable {instance}") instance.termination_a.cable = None instance.termination_a.save() if instance.termination_b is not None: - logger.debug("Nullifying termination B for cable {}".format(instance)) + logger.debug(f"Nullifying termination B for cable {instance}") instance.termination_b.cable = None instance.termination_b.save() - # If this Cable was part of any complete end-to-end paths, tear them down. - for endpoint in endpoints: - logger.debug(f"Removing path information for {endpoint}") - if hasattr(endpoint, 'connected_endpoint'): - endpoint.connected_endpoint = None - endpoint.connection_status = None - endpoint.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_cablepaths(origin) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py new file mode 100644 index 000000000..2baa91622 --- /dev/null +++ b/netbox/dcim/utils.py @@ -0,0 +1,65 @@ +from django.contrib.contenttypes.models import ContentType + +from .models import FrontPort, RearPort + + +def object_to_path_node(obj): + return f'{obj._meta.model_name}:{obj.pk}' + + +def objects_to_path(*obj_list): + return [object_to_path_node(obj) for obj in obj_list] + + +def path_node_to_object(repr): + model_name, object_id = repr.split(':') + model_class = ContentType.objects.get(model=model_name).model_class() + return model_class.objects.get(pk=int(object_id)) + + +def trace_paths(node): + destination = None + path = [] + position_stack = [] + + if node.cable is None: + return [] + + while node.cable is not None: + + # Follow the cable to its far-end termination + path.append(object_to_path_node(node.cable)) + peer_termination = node.get_cable_peer() + + # Follow a FrontPort to its corresponding RearPort + if isinstance(peer_termination, FrontPort): + path.append(object_to_path_node(peer_termination)) + position_stack.append(peer_termination.rear_port_position) + node = peer_termination.rear_port + path.append(object_to_path_node(node)) + + # Follow a RearPort to its corresponding FrontPort + elif isinstance(peer_termination, RearPort): + path.append(object_to_path_node(peer_termination)) + if position_stack: + position = position_stack.pop() + node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) + path.append(object_to_path_node(node)) + else: + # No position indicated, so we have to trace _all_ peer FrontPorts + paths = [] + for frontport in FrontPort.objects.filter(rear_port=peer_termination): + branches = trace_paths(frontport) + if branches: + for branch, destination in branches: + paths.append(([*path, object_to_path_node(frontport), *branch], destination)) + else: + paths.append(([*path, object_to_path_node(frontport)], None)) + return paths + + # Anything else marks the end of the path + else: + destination = peer_termination + break + + return [(path, destination)] diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index a317dc937..706801dd1 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -75,65 +75,19 @@ Virtual interface {% elif iface.is_wireless %} Wireless interface - {% elif iface.connected_endpoint.name %} - {# Connected to an Interface #} - - - {{ iface.connected_endpoint.device }} - - - - - - {{ iface.connected_endpoint }} - - - - {% elif iface.connected_endpoint.term_side %} - {# Connected to a CircuitTermination #} - {% with iface.connected_endpoint.get_peer_termination as peer_termination %} - {% if peer_termination %} - {% if peer_termination.connected_endpoint %} - - - {{ peer_termination.connected_endpoint.device }} -
- via - - {{ iface.connected_endpoint.circuit.provider }} - {{ iface.connected_endpoint.circuit }} - - - - - {{ peer_termination.connected_endpoint }} - - {% else %} - - - {{ peer_termination.site }} - - via - - {{ iface.connected_endpoint.circuit.provider }} - {{ iface.connected_endpoint.circuit }} - - - {% endif %} + {% else %} + {% with path_count=iface.get_connections.count %} + {% if path_count > 1 %} + Multiple connections + {% elif path_count %} + {% with endpoint=iface.get_connections.first.destination %} + {{ endpoint.parent }} + {{ endpoint }} + {% endwith %} {% else %} - - - - {{ iface.connected_endpoint.circuit.provider }} - {{ iface.connected_endpoint.circuit }} - - + Not connected {% endif %} {% endwith %} - {% else %} - - Not connected - {% endif %} {# Buttons #}