Closes #6071: Cable traces now traverse circuits

This commit is contained in:
Jeremy Stretch 2021-04-01 14:31:10 -04:00
parent d57222328b
commit 96759af86f
17 changed files with 302 additions and 79 deletions

View File

@ -112,6 +112,7 @@ A new provider network model has been introduced to represent the boundary of a
* [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields
* [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead)
* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view
* [#6071](https://github.com/netbox-community/netbox/issues/6071) - Cable traces now traverse circuits
### REST API Changes
@ -131,6 +132,7 @@ A new provider network model has been introduced to represent the boundary of a
* The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
* circuits.CircuitTermination
* Added the `provider_network` field
* Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
* circuits.ProviderNetwork
* Added the `/api/circuits/provider-networks/` endpoint
* dcim.Device

View File

@ -60,7 +60,7 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
]
class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer):
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = NestedSiteSerializer()
provider_network = NestedProviderNetworkSerializer()
@ -69,7 +69,6 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd
model = CircuitTermination
fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable',
]
@ -91,7 +90,7 @@ class CircuitSerializer(PrimaryModelSerializer):
]
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False)
@ -103,5 +102,5 @@ class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializ
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied',
'_occupied',
]

View File

@ -1,4 +1,3 @@
from django.db.models import Prefetch
from rest_framework.routers import APIRootView
from circuits import filters
@ -60,7 +59,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', '_path__destination', 'cable'
'circuit', 'site', 'provider_network', 'cable'
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet

View File

@ -207,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
).distinct()
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -0,0 +1,32 @@
from django.db import migrations
from django.db.models import Q
def delete_obsolete_cablepaths(apps, schema_editor):
"""
Delete all CablePath instances which originate or terminate at a CircuitTermination.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
CablePath = apps.get_model('dcim', 'CablePath')
ct = ContentType.objects.get_for_model(CircuitTermination)
CablePath.objects.filter(Q(origin_type=ct) | Q(destination_type=ct)).delete()
class Migration(migrations.Migration):
dependencies = [
('circuits', '0028_cache_circuit_terminations'),
]
operations = [
migrations.RemoveField(
model_name='circuittermination',
name='_path',
),
migrations.RunPython(
code=delete_obsolete_cablepaths,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -294,7 +294,7 @@ class Circuit(PrimaryModel):
@extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
class CircuitTermination(ChangeLoggedModel, CableTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,

View File

@ -381,12 +381,6 @@ class CircuitTerminationTestCase(TestCase):
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class ProviderNetworkTestCase(TestCase):
queryset = ProviderNetwork.objects.all()

View File

@ -219,8 +219,6 @@ class CircuitView(generic.ObjectView):
).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
@ -228,8 +226,6 @@ class CircuitView(generic.ObjectView):
).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return {
'termination_a': termination_a,

View File

@ -2,12 +2,10 @@ from django.core.management.base import BaseCommand
from django.core.management.color import no_style
from django.db import connection
from circuits.models import CircuitTermination
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath
ENDPOINT_MODELS = (
CircuitTermination,
ConsolePort,
ConsoleServerPort,
Interface,

View File

@ -394,6 +394,8 @@ class CablePath(BigIDModel):
"""
Create a new CablePath instance as traced from the given path origin.
"""
from circuits.models import CircuitTermination
if origin is None or origin.cable is None:
return None
@ -441,6 +443,23 @@ class CablePath(BigIDModel):
# No corresponding FrontPort found for the RearPort
break
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
elif isinstance(peer_termination, CircuitTermination):
path.append(object_to_path_node(peer_termination))
# Get peer CircuitTermination
node = peer_termination.get_peer_termination()
if node:
path.append(object_to_path_node(node))
if node.provider_network:
destination = node.provider_network
break
elif node.site and not node.cable:
destination = node.site
break
else:
# No peer CircuitTermination exists; halt the trace
break
# Anything else marks the end of the path
else:
destination = peer_termination
@ -486,15 +505,26 @@ class CablePath(BigIDModel):
return path
def get_cable_ids(self):
"""
Return all Cable IDs within the path.
"""
cable_ct = ContentType.objects.get_for_model(Cable).pk
cable_ids = []
for node in self.path:
ct, id = decompile_path_node(node)
if ct == cable_ct:
cable_ids.append(id)
return cable_ids
def get_total_length(self):
"""
Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive.
"""
cable_ids = [
# Starting from the first element, every third element in the path should be a Cable
decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
]
cable_ids = self.get_cable_ids()
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
total_length = cables.aggregate(total=Sum('_abs_length'))['total']
is_definitive = len(cables) == len(cable_ids)

View File

@ -160,7 +160,7 @@ class CableTermination(models.Model):
class PathEndpoint(models.Model):
"""
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination.
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
@ -184,10 +184,11 @@ class PathEndpoint(models.Model):
# Construct the complete path
path = [self, *self._path.get_path()]
while (len(path) + 1) % 3:
if self._path.destination:
path.append(self._path.destination)
while len(path) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort)
path.append(None)
path.append(self._path.destination)
path.insert(-1, None)
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))

View File

@ -1,10 +1,12 @@
CABLETERMINATION = """
{% if value %}
{% if value.parent_object %}
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
&mdash;
&mdash;
{% endif %}
"""

View File

@ -229,40 +229,6 @@ class CablePathTestCase(TestCase):
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_105_interface_to_circuittermination(self):
"""
[IF1] --C1-- [CT1A]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
path1 = self.assertPathExists(
origin=interface1,
destination=circuittermination1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=circuittermination1,
destination=interface1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interface1.refresh_from_db()
circuittermination1.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(circuittermination1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_201_single_path_via_pass_through(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
@ -820,6 +786,213 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 1)
def test_208_circuittermination(self):
"""
[IF1] --C1-- [CT1]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
# Check for incomplete path
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, circuittermination1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
interface1.refresh_from_db()
self.assertPathIsNotSet(interface1)
def test_209_circuit_to_interface(self):
"""
[IF1] --C1-- [CT1] [CT2] --C2-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
cable2 = Cable(termination_a=circuittermination2, termination_b=interface2)
cable2.save()
# Check for paths
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, circuittermination1, circuittermination2, cable2),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, circuittermination2, circuittermination1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
origin=interface1,
destination=self.site,
path=(cable1, circuittermination1, circuittermination2),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
interface1.refresh_from_db()
interface2.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsNotSet(interface2)
def test_210_circuit_to_site(self):
"""
[IF1] --C1-- [CT1] [CT2] --> [Site2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
site2 = Site.objects.create(name='Site 2', slug='site-2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=site2,
path=(cable1, circuittermination1, circuittermination2),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
interface1.refresh_from_db()
self.assertPathIsNotSet(interface1)
def test_211_circuit_to_providernetwork(self):
"""
[IF1] --C1-- [CT1] [CT2] --> [PN1]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=providernetwork,
path=(cable1, circuittermination1, circuittermination2),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
interface1.refresh_from_db()
self.assertPathIsNotSet(interface1)
def test_212_multiple_paths_via_circuit(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3]
[IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2
cable2.save()
cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1
cable3.save()
cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2
cable4.save()
cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1
cable5.save()
cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2
cable6.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2,
cable4, rearport2, frontport2_1, cable5
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2,
cable4, rearport2, frontport2_2, cable6
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1,
cable3, rearport1, frontport1_1, cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1,
cable3, rearport1, frontport1_2, cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cables 3-4
cable3.delete()
cable4.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]

View File

@ -104,21 +104,6 @@
{% endif %}
</td>
</tr>
<tr>
<td>IP Addressing</td>
<td>
{% if termination.connected_endpoint %}
{% for ip in termination.ip_addresses %}
{% if not forloop.first %}<br />{% endif %}
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<td>Cross-Connect</td>
<td>{{ termination.xconnect_id|placeholder }}</td>

View File

@ -27,6 +27,8 @@
{# Cable #}
{% if cable %}
{% include 'dcim/trace/cable.html' %}
{% elif far_end %}
{% include 'dcim/trace/attachment.html' %}
{% endif %}
{# Far end #}
@ -43,6 +45,8 @@
{% if forloop.last %}
{% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %}
{% endif %}
{% elif far_end %}
{% include 'dcim/trace/object.html' with object=far_end %}
{% endif %}
{% if forloop.last %}

View File

@ -0,0 +1,5 @@
{% load helpers %}
<div class="cable" style="border-left-color: #c0c0c0; border-left-style: dashed">
<strong>Attachment</strong>
</div>

View File

@ -0,0 +1,3 @@
<div class="node">
<strong><a href="{{ object.get_absolute_url }}">{{ object }}</a></strong>
</div>