Просмотр исходного кода

Initial work on cable paths (WIP)

Jeremy Stretch 5 лет назад
Родитель
Сommit
587e6fcf72

+ 11 - 0
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.exceptions import ValidationError
 from django.core.validators import MinValueValidator, MaxValueValidator
 from django.core.validators import MinValueValidator, MaxValueValidator
 from django.db import models
 from django.db import models
@@ -50,3 +52,12 @@ class MACAddressField(models.Field):
         if not value:
         if not value:
             return None
             return None
         return str(self.to_python(value))
         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)

+ 8 - 0
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)

+ 27 - 0
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')),
+            ],
+        ),
+    ]

+ 1 - 0
netbox/dcim/models/__init__.py

@@ -8,6 +8,7 @@ from .sites import *
 __all__ = (
 __all__ = (
     'BaseInterface',
     'BaseInterface',
     'Cable',
     'Cable',
+    'CablePath',
     'CableTermination',
     'CableTermination',
     'ConsolePort',
     'ConsolePort',
     'ConsolePortTemplate',
     'ConsolePortTemplate',

+ 18 - 5
netbox/dcim/models/device_components.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -32,6 +33,7 @@ __all__ = (
     'FrontPort',
     'FrontPort',
     'Interface',
     'Interface',
     'InventoryItem',
     'InventoryItem',
+    'PathEndpoint',
     'PowerOutlet',
     'PowerOutlet',
     'PowerPort',
     'PowerPort',
     'RearPort',
     'RearPort',
@@ -250,12 +252,23 @@ class CableTermination(models.Model):
         return endpoints
         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
 # Console ports
 #
 #
 
 
 @extras_features('export_templates', 'webhooks')
 @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.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
     """
@@ -303,7 +316,7 @@ class ConsolePort(CableTermination, ComponentModel):
 #
 #
 
 
 @extras_features('webhooks')
 @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.
     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')
 @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.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
     """
@@ -493,7 +506,7 @@ class PowerPort(CableTermination, ComponentModel):
 #
 #
 
 
 @extras_features('webhooks')
 @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.
     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')
 @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.
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
     """

+ 42 - 0
netbox/dcim/models/devices.py

@@ -14,6 +14,9 @@ from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants 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.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
@@ -25,6 +28,7 @@ from .device_components import *
 
 
 __all__ = (
 __all__ = (
     'Cable',
     'Cable',
+    'CablePath',
     'Device',
     'Device',
     'DeviceRole',
     'DeviceRole',
     'DeviceType',
     'DeviceType',
@@ -1154,6 +1158,44 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
         return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
         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
 # Virtual chassis
 #
 #

+ 57 - 40
netbox/dcim/signals.py

@@ -1,10 +1,34 @@
 import logging
 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, pre_delete
+from django.db import transaction
 from django.dispatch import receiver
 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)
 @receiver(post_save, sender=VirtualChassis)
@@ -32,7 +56,7 @@ def clear_virtualchassis_members(instance, **kwargs):
 
 
 
 
 @receiver(post_save, sender=Cable)
 @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
     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
     # Cache the Cable on its two termination points
     if instance.termination_a.cable != instance:
     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.cable = instance
         instance.termination_a.save()
         instance.termination_a.save()
     if instance.termination_b.cable != instance:
     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.cable = instance
         instance.termination_b.save()
         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)
 @receiver(pre_delete, sender=Cable)
@@ -81,22 +92,28 @@ def nullify_connected_endpoints(instance, **kwargs):
     """
     """
     logger = logging.getLogger('netbox.dcim.cable')
     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
     # Disassociate the Cable from its termination points
     if instance.termination_a is not None:
     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.cable = None
         instance.termination_a.save()
         instance.termination_a.save()
     if instance.termination_b is not None:
     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.cable = None
         instance.termination_b.save()
         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)

+ 65 - 0
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)]

+ 10 - 56
netbox/templates/dcim/inc/interface.html

@@ -75,65 +75,19 @@
         <td colspan="2" class="text-muted">Virtual interface</td>
         <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.is_wireless %}
     {% elif iface.is_wireless %}
         <td colspan="2" class="text-muted">Wireless interface</td>
         <td colspan="2" class="text-muted">Wireless interface</td>
-    {% elif iface.connected_endpoint.name %}
-        {# Connected to an Interface #}
-        <td>
-            <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">
-                {{ iface.connected_endpoint.device }}
-            </a>
-        </td>
-        <td>
-            <a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}">
-                <span title="{{ iface.connected_endpoint.get_type_display }}">
-                    {{ iface.connected_endpoint }}
-                </span>
-            </a>
-        </td>
-    {% 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 %}
-                    <td>
-                        <a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">
-                            {{ peer_termination.connected_endpoint.device }}
-                        </a><br/>
-                        <small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
-                            <a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
-                                {{ iface.connected_endpoint.circuit.provider }}
-                                {{ iface.connected_endpoint.circuit }}
-                            </a>
-                        </small>
-                    </td>
-                    <td>
-                        {{ peer_termination.connected_endpoint }}
-                    </td>
-                {% else %}
-                    <td colspan="2">
-                        <a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">
-                            {{ peer_termination.site }}
-                        </a>
-                        via <i class="fa fa-fw fa-globe" title="Circuit"></i>
-                        <a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
-                            {{ iface.connected_endpoint.circuit.provider }}
-                            {{ iface.connected_endpoint.circuit }}
-                        </a>
-                    </td>
-                {% endif %}
+    {% else %}
+        {% with path_count=iface.get_connections.count %}
+            {% if path_count > 1 %}
+                <td colspan="2">Multiple connections</td>
+            {% elif path_count %}
+                {% with endpoint=iface.get_connections.first.destination %}
+                    <td><a href="{{ endpoint.parent.get_absolute_url }}">{{ endpoint.parent }}</a></td>
+                    <td><a href="{{ endpoint.get_absolute_url }}">{{ endpoint }}</a></td>
+                {% endwith %}
             {% else %}
             {% else %}
-                <td colspan="2">
-                    <i class="fa fa-fw fa-globe" title="Circuit"></i>
-                    <a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
-                        {{ iface.connected_endpoint.circuit.provider }}
-                        {{ iface.connected_endpoint.circuit }}
-                    </a>
-                </td>
+                <td colspan="2" class="text-muted">Not connected</td>
             {% endif %}
             {% endif %}
         {% endwith %}
         {% endwith %}
-    {% else %}
-        <td colspan="2">
-            <span class="text-muted">Not connected</span>
-        </td>
     {% endif %}
     {% endif %}
 
 
     {# Buttons #}
     {# Buttons #}