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

Closes #6071: Cable traces now traverse circuits

Jeremy Stretch 4 лет назад
Родитель
Сommit
96759af86f

+ 2 - 0
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

+ 3 - 4
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',
         ]

+ 1 - 2
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

+ 1 - 1
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',

+ 32 - 0
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
+        ),
+    ]

+ 1 - 1
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,

+ 0 - 6
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()

+ 0 - 4
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,

+ 0 - 2
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,

+ 34 - 4
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)

+ 5 - 4
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))

+ 4 - 2
netbox/dcim/tables/template_code.py

@@ -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 %}
 """
 

+ 207 - 34
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]

+ 0 - 15
netbox/templates/circuits/inc/circuit_termination.html

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

+ 4 - 0
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 %}

+ 5 - 0
netbox/templates/dcim/trace/attachment.html

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

+ 3 - 0
netbox/templates/dcim/trace/object.html

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