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:
@ -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
8
netbox/dcim/managers.py
Normal 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)
|
27
netbox/dcim/migrations/0120_cablepath.py
Normal file
27
netbox/dcim/migrations/0120_cablepath.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -8,6 +8,7 @@ from .sites import *
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'Cable',
|
||||
'CablePath',
|
||||
'CableTermination',
|
||||
'ConsolePort',
|
||||
'ConsolePortTemplate',
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
65
netbox/dcim/utils.py
Normal 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)]
|
@ -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 #}
|
||||
|
Reference in New Issue
Block a user