1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Initial work on cable paths (WIP)

This commit is contained in:
Jeremy Stretch
2020-09-30 15:07:56 -04:00
parent 12e2537222
commit 587e6fcf72
9 changed files with 239 additions and 101 deletions

View File

@ -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
netbox/dcim/managers.py Normal file
View File

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

View File

@ -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')),
],
),
]

View File

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

View File

@ -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.
"""

View File

@ -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
#

View File

@ -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
netbox/dcim/utils.py Normal file
View File

@ -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)]

View File

@ -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 #}