From 96759af86f8a74580626de18f7c5250bc27002ad Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 14:31:10 -0400 Subject: [PATCH] Closes #6071: Cable traces now traverse circuits --- docs/release-notes/version-2.11.md | 2 + netbox/circuits/api/serializers.py | 7 +- netbox/circuits/api/views.py | 3 +- netbox/circuits/filters.py | 2 +- .../migrations/0029_circuit_tracing.py | 32 +++ netbox/circuits/models.py | 2 +- netbox/circuits/tests/test_filters.py | 6 - netbox/circuits/views.py | 4 - .../dcim/management/commands/trace_paths.py | 2 - netbox/dcim/models/cables.py | 38 ++- netbox/dcim/models/device_components.py | 9 +- netbox/dcim/tables/template_code.py | 6 +- netbox/dcim/tests/test_cablepaths.py | 241 +++++++++++++++--- .../circuits/inc/circuit_termination.html | 15 -- netbox/templates/dcim/cable_trace.html | 4 + netbox/templates/dcim/trace/attachment.html | 5 + netbox/templates/dcim/trace/object.html | 3 + 17 files changed, 302 insertions(+), 79 deletions(-) create mode 100644 netbox/circuits/migrations/0029_circuit_tracing.py create mode 100644 netbox/templates/dcim/trace/attachment.html create mode 100644 netbox/templates/dcim/trace/object.html diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 2043b02e9..9fa97cf96 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -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 diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 794235dee..014ec0fc8 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -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', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 83c4a8fa6..c037bc5fd 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -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 diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fa9f964c2..f5d81c7bd 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -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', diff --git a/netbox/circuits/migrations/0029_circuit_tracing.py b/netbox/circuits/migrations/0029_circuit_tracing.py new file mode 100644 index 000000000..bddb38bb6 --- /dev/null +++ b/netbox/circuits/migrations/0029_circuit_tracing.py @@ -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 + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 10534d1cc..d838d93d2 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -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, diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index dca6b317d..448e42368 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -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() diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f0aefd346..92e53c30f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -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, diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 06b5bdec0..fd5f9cfab 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -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, diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index b20fc7080..806b74054 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -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) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 1f2911d2d..a7357ae79 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -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)) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 4324f802f..2582a7117 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,10 +1,12 @@ CABLETERMINATION = """ {% if value %} + {% if value.parent_object %} {{ value.parent_object }} - {{ value }} + {% endif %} + {{ value }} {% else %} - — + — {% endif %} """ diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 37d7014f1..4a6c6639f 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -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] diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 5a5a4788d..6dc079f0f 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -104,21 +104,6 @@ {% endif %} - - IP Addressing - - {% if termination.connected_endpoint %} - {% for ip in termination.ip_addresses %} - {% if not forloop.first %}
{% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - - {% endif %} - - Cross-Connect {{ termination.xconnect_id|placeholder }} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index db134889f..b4e3e8d43 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -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 %} diff --git a/netbox/templates/dcim/trace/attachment.html b/netbox/templates/dcim/trace/attachment.html new file mode 100644 index 000000000..450d74bc8 --- /dev/null +++ b/netbox/templates/dcim/trace/attachment.html @@ -0,0 +1,5 @@ +{% load helpers %} + +
+ Attachment +
diff --git a/netbox/templates/dcim/trace/object.html b/netbox/templates/dcim/trace/object.html new file mode 100644 index 000000000..72e5b5787 --- /dev/null +++ b/netbox/templates/dcim/trace/object.html @@ -0,0 +1,3 @@ +
+ {{ object }} +