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

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.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)

+ 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__ = (
     'BaseInterface',
     'Cable',
+    'CablePath',
     'CableTermination',
     'ConsolePort',
     'ConsolePortTemplate',

+ 18 - 5
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.
     """

+ 42 - 0
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
 #

+ 57 - 40
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)

+ 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>
     {% elif iface.is_wireless %}
         <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 %}
-                <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 %}
         {% endwith %}
-    {% else %}
-        <td colspan="2">
-            <span class="text-muted">Not connected</span>
-        </td>
     {% endif %}
 
     {# Buttons #}