Repository: nautobot/nautobot

Issue:
Unable to Export Power Connections if connected to a PowerFeed
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.6
* Nautobot version: 1.0b3

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1.  Create a power feed
2.  Create a connection from a device to the feed
3.   Go to `/dcim/power-connections/` and click export

<!-- What did you expect to happen? -->
### Expected Behavior

A .csv file is generated

<!-- What happened instead? -->
### Observed Behavior

```
Stack trace
builtins:AttributeError: 'PowerFeed' object has no attribute 'device'
Traceback(most recent call last):
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/nautobot/core/middleware.py", line 127, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/nautobot/core/middleware.py", line 111, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/nautobot/core/middleware.py", line 97, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/utils/deprecation.py", line 114, in __call__
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 1206, in _wrapper
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/exception.py", line 47, in inner
File "/usr/local/lib/python3.6/site-packages/django/core/handlers/base.py", line 181, in _get_response
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 563, in wrapper
File "/usr/local/lib/python3.6/site-packages/django/views/generic/base.py", line 70, in view
File "/usr/local/lib/python3.6/site-packages/nautobot/utilities/views.py", line 94, in dispatch
File "/usr/local/lib/python3.6/site-packages/newrelic/hooks/framework_django.py", line 953, in wrapper
File "/usr/local/lib/python3.6/site-packages/django/views/generic/base.py", line 98, in dispatch
File "/usr/local/lib/python3.6/site-packages/nautobot/core/views/generic.py", line 184, in get
File "/usr/local/lib/python3.6/site-packages/nautobot/dcim/views.py", line 2479, in queryset_to_csv
```

![Screen Shot 2021-04-13 at 2 04 17 PM](https://user-images.githubusercontent.com/14947575/114613941-2adfc180-9c61-11eb-966e-a57852b60bd4.png)



Discussion:
FWIW this appears to be an issue in NetBox 2.10 as well, though perhaps not a known issue.

Resolving patch:
diff --git a/nautobot/dcim/views.py b/nautobot/dcim/views.py
--- a/nautobot/dcim/views.py
+++ b/nautobot/dcim/views.py
@@ -1,4 +1,4 @@
-from collections import OrderedDict
+from collections import OrderedDict, namedtuple
 
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
@@ -2431,8 +2431,62 @@ class CableBulkDeleteView(generic.BulkDeleteView):
 #
 
 
-class ConsoleConnectionsListView(generic.ObjectListView):
-    queryset = ConsolePort.objects.filter(_path__isnull=False).order_by("device")
+class ConnectionsListView(generic.ObjectListView):
+
+    CSVRow = namedtuple("CSVRow", ["device", "name", "dest_device", "dest_name", "reachable"])
+
+    def queryset_to_csv_body_data(self):
+        """
+        The headers may differ from view to view but the formatting of the CSV data is the same.
+        """
+        csv_body_data = []
+        for obj in self.queryset:
+            # The connected endpoint may or may not be associated with a Device (e.g., CircuitTerminations are not)
+            # and may or may not have a name of its own (e.g., CircuitTerminations do not)
+            dest_device = None
+            dest_name = None
+            if obj.connected_endpoint:
+                if hasattr(obj.connected_endpoint, "device"):
+                    dest_device = obj.connected_endpoint.device.identifier
+                if hasattr(obj.connected_endpoint, "name"):
+                    dest_name = obj.connected_endpoint.name
+
+            # In the case where a connection exists between two like endpoints,
+            # for consistency of output we want to ensure that it's always represented as
+            # ("device a", "interface a", "device b", "interface b") rather than
+            # ("device b", "interface b", "device a", "interface a")
+            if obj.__class__ == obj.connected_endpoint.__class__ and (
+                obj.device.identifier > obj.connected_endpoint.device.identifier
+                or (
+                    obj.device.identifier == obj.connected_endpoint.device.identifier
+                    and obj.name > obj.connected_endpoint.name
+                )
+            ):
+                # Swap the two endpoints around for consistent output as described above
+                row = self.CSVRow(
+                    device=dest_device,
+                    name=dest_name,
+                    dest_device=obj.device.identifier,
+                    dest_name=obj.name,
+                    reachable=str(obj.path.is_active),
+                )
+            else:
+                # Existing order of endpoints is fine and correct
+                row = self.CSVRow(
+                    device=obj.device.identifier,
+                    name=obj.name,
+                    dest_device=dest_device,
+                    dest_name=dest_name,
+                    reachable=str(obj.path.is_active),
+                )
+
+            csv_body_data.append(csv_format(row))
+
+        return sorted(csv_body_data)
+
+
+class ConsoleConnectionsListView(ConnectionsListView):
+    queryset = ConsolePort.objects.filter(_path__isnull=False)
     filterset = filters.ConsoleConnectionFilterSet
     filterset_form = forms.ConsoleConnectionFilterForm
     table = tables.ConsoleConnectionTable
@@ -2441,19 +2495,9 @@ class ConsoleConnectionsListView(generic.ObjectListView):
     def queryset_to_csv(self):
         csv_data = [
             # Headers
-            ",".join(["console_server", "port", "device", "console_port", "reachable"])
+            ",".join(["device", "console_port", "console_server", "port", "reachable"])
         ]
-        for obj in self.queryset:
-            csv = csv_format(
-                [
-                    obj._path.destination.device.identifier if obj._path.destination else None,
-                    obj._path.destination.name if obj._path.destination else None,
-                    obj.device.identifier,
-                    obj.name,
-                    obj._path.is_active,
-                ]
-            )
-            csv_data.append(csv)
+        csv_data.extend(self.queryset_to_csv_body_data())
 
         return "\n".join(csv_data)
 
@@ -2461,8 +2505,8 @@ def extra_context(self):
         return {"title": "Console Connections"}
 
 
-class PowerConnectionsListView(generic.ObjectListView):
-    queryset = PowerPort.objects.filter(_path__isnull=False).order_by("device")
+class PowerConnectionsListView(ConnectionsListView):
+    queryset = PowerPort.objects.filter(_path__isnull=False)
     filterset = filters.PowerConnectionFilterSet
     filterset_form = forms.PowerConnectionFilterForm
     table = tables.PowerConnectionTable
@@ -2471,19 +2515,9 @@ class PowerConnectionsListView(generic.ObjectListView):
     def queryset_to_csv(self):
         csv_data = [
             # Headers
-            ",".join(["pdu", "outlet", "device", "power_port", "reachable"])
+            ",".join(["device", "power_port", "pdu", "outlet", "reachable"])
         ]
-        for obj in self.queryset:
-            csv = csv_format(
-                [
-                    obj._path.destination.device.identifier if obj._path.destination else None,
-                    obj._path.destination.name if obj._path.destination else None,
-                    obj.device.identifier,
-                    obj.name,
-                    obj._path.is_active,
-                ]
-            )
-            csv_data.append(csv)
+        csv_data.extend(self.queryset_to_csv_body_data())
 
         return "\n".join(csv_data)
 
@@ -2491,12 +2525,17 @@ def extra_context(self):
         return {"title": "Power Connections"}
 
 
-class InterfaceConnectionsListView(generic.ObjectListView):
-    queryset = Interface.objects.filter(
-        # Avoid duplicate connections by only selecting the lower PK in a connected pair
-        _path__isnull=False,
+class InterfaceConnectionsListView(ConnectionsListView):
+    queryset = Interface.objects.filter(_path__isnull=False).exclude(
+        # If an Interface is connected to another Interface, avoid returning both (A, B) and (B, A)
+        # Unfortunately we can't use something consistent to pick which pair to exclude (such as device or name)
+        # as _path.destination is a GenericForeignKey without a corresponding GenericRelation and so cannot be
+        # used for reverse querying.
+        # The below at least ensures uniqueness, but doesn't guarantee whether we get (A, B) or (B, A);
+        # we fix it up to be consistently (A, B) in queryset_to_csv_body_data().
+        _path__destination_type=ContentType.objects.get_for_model(Interface),
         pk__lt=F("_path__destination_id"),
-    ).order_by("device")
+    )
     filterset = filters.InterfaceConnectionFilterSet
     filterset_form = forms.InterfaceConnectionFilterForm
     table = tables.InterfaceConnectionTable
@@ -2507,17 +2546,7 @@ def queryset_to_csv(self):
             # Headers
             ",".join(["device_a", "interface_a", "device_b", "interface_b", "reachable"])
         ]
-        for obj in self.queryset:
-            csv = csv_format(
-                [
-                    obj._path.destination.device.identifier if obj._path.destination else None,
-                    obj._path.destination.name if obj._path.destination else None,
-                    obj.device.identifier,
-                    obj.name,
-                    obj._path.is_active,
-                ]
-            )
-            csv_data.append(csv)
+        csv_data.extend(self.queryset_to_csv_body_data())
 
         return "\n".join(csv_data)
 


Test patch:
diff --git a/nautobot/dcim/tests/test_views.py b/nautobot/dcim/tests/test_views.py
--- a/nautobot/dcim/tests/test_views.py
+++ b/nautobot/dcim/tests/test_views.py
@@ -5,16 +5,19 @@
 
 from django.contrib.auth import get_user_model
 
-# from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 from netaddr import EUI
 
+from nautobot.circuits.choices import CircuitTerminationSideChoices
+from nautobot.circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from nautobot.dcim.choices import *
 from nautobot.dcim.constants import *
 from nautobot.dcim.models import *
 from nautobot.extras.models import Status
 from nautobot.ipam.models import VLAN, IPAddress
+from nautobot.users.models import ObjectPermission
 from nautobot.utilities.testing import ViewTestCases, post_data
 
 # Use the proper swappable User model
@@ -1766,6 +1769,195 @@ def setUpTestData(cls):
         }
 
 
+class ConsoleConnectionsTestCase(ViewTestCases.ListObjectsViewTestCase):
+    """
+    Test the ConsoleConnectionsListView.
+    """
+
+    def _get_base_url(self):
+        return "dcim:console_connections_{}"
+
+    model = ConsolePort
+
+    @classmethod
+    def setUpTestData(cls):
+        device_1 = create_test_device("Device 1")
+        device_2 = create_test_device("Device 2")
+
+        serverports = (
+            ConsoleServerPort.objects.create(device=device_2, name="Console Server Port 1"),
+            ConsoleServerPort.objects.create(device=device_2, name="Console Server Port 2"),
+        )
+        rearport = RearPort.objects.create(device=device_2, type=PortTypeChoices.TYPE_8P8C)
+
+        consoleports = (
+            ConsolePort.objects.create(device=device_1, name="Console Port 1"),
+            ConsolePort.objects.create(device=device_1, name="Console Port 2"),
+            ConsolePort.objects.create(device=device_1, name="Console Port 3"),
+        )
+
+        Cable.objects.create(
+            termination_a=consoleports[0], termination_b=serverports[0], status=Status.objects.get(slug="connected")
+        )
+        Cable.objects.create(
+            termination_a=consoleports[1], termination_b=serverports[1], status=Status.objects.get(slug="connected")
+        )
+        Cable.objects.create(
+            termination_a=consoleports[2], termination_b=rearport, status=Status.objects.get(slug="connected")
+        )
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_queryset_to_csv(self):
+        """This view has a custom queryset_to_csv() implementation."""
+        response = self.client.get("{}?export".format(self._get_url("list")))
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get("Content-Type"), "text/csv")
+        self.assertEqual(
+            """\
+device,console_port,console_server,port,reachable
+Device 1,Console Port 1,Device 2,Console Server Port 1,True
+Device 1,Console Port 2,Device 2,Console Server Port 2,True
+Device 1,Console Port 3,,,False""",
+            response.content.decode(response.charset),
+        )
+
+
+class PowerConnectionsTestCase(ViewTestCases.ListObjectsViewTestCase):
+    """
+    Test the PowerConnectionsListView.
+    """
+
+    def _get_base_url(self):
+        return "dcim:power_connections_{}"
+
+    model = PowerPort
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name="Site 1", slug="site-1")
+
+        device_1 = create_test_device("Device 1")
+        device_2 = create_test_device("Device 2")
+
+        powerports = (
+            PowerPort.objects.create(device=device_1, name="Power Port 1"),
+            PowerPort.objects.create(device=device_1, name="Power Port 2"),
+            PowerPort.objects.create(device=device_1, name="Power Port 3"),
+        )
+
+        poweroutlets = (
+            PowerOutlet.objects.create(device=device_2, name="Power Outlet 1", power_port=powerports[0]),
+            PowerOutlet.objects.create(device=device_2, name="Power Outlet 2", power_port=powerports[1]),
+        )
+
+        powerpanel = PowerPanel.objects.create(site=site, name="Power Panel 1")
+        powerfeed = PowerFeed.objects.create(power_panel=powerpanel, name="Power Feed 1")
+
+        Cable.objects.create(
+            termination_a=powerports[2], termination_b=powerfeed, status=Status.objects.get(slug="connected")
+        )
+        # Creating a PowerOutlet with a PowerPort via the ORM does *not* automatically cable the two together. Bug?
+        Cable.objects.create(
+            termination_a=powerports[0], termination_b=poweroutlets[0], status=Status.objects.get(slug="connected")
+        )
+        Cable.objects.create(
+            termination_a=powerports[1], termination_b=poweroutlets[1], status=Status.objects.get(slug="connected")
+        )
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_queryset_to_csv(self):
+        """This view has a custom queryset_to_csv() implementation."""
+        response = self.client.get("{}?export".format(self._get_url("list")))
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get("Content-Type"), "text/csv")
+        self.assertEqual(
+            """\
+device,power_port,pdu,outlet,reachable
+Device 1,Power Port 1,Device 2,Power Outlet 1,True
+Device 1,Power Port 2,Device 2,Power Outlet 2,True
+Device 1,Power Port 3,,Power Feed 1,True""",
+            response.content.decode(response.charset),
+        )
+
+
+class InterfaceConnectionsTestCase(ViewTestCases.ListObjectsViewTestCase):
+    """
+    Test the InterfaceConnectionsListView.
+    """
+
+    def _get_base_url(self):
+        return "dcim:interface_connections_{}"
+
+    model = Interface
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name="Site 1", slug="site-1")
+
+        device_1 = create_test_device("Device 1")
+        device_2 = create_test_device("Device 2")
+
+        cls.interfaces = (
+            Interface.objects.create(device=device_1, name="Interface 1", type=InterfaceTypeChoices.TYPE_1GE_SFP),
+            Interface.objects.create(device=device_1, name="Interface 2", type=InterfaceTypeChoices.TYPE_1GE_SFP),
+            Interface.objects.create(device=device_1, name="Interface 3", type=InterfaceTypeChoices.TYPE_1GE_SFP),
+        )
+
+        cls.device_2_interface = Interface.objects.create(
+            device=device_2, name="Interface 1", type=InterfaceTypeChoices.TYPE_1GE_SFP
+        )
+        rearport = RearPort.objects.create(device=device_2, type=PortTypeChoices.TYPE_8P8C)
+
+        provider = Provider.objects.create(name="Provider 1", slug="provider-1")
+        circuittype = CircuitType.objects.create(name="Circuit Type A", slug="circuit-type-a")
+        circuit = Circuit.objects.create(cid="Circuit 1", provider=provider, type=circuittype)
+        circuittermination = CircuitTermination.objects.create(
+            circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A, site=site
+        )
+
+        connected = Status.objects.get(slug="connected")
+
+        Cable.objects.create(termination_a=cls.interfaces[0], termination_b=cls.device_2_interface, status=connected)
+        Cable.objects.create(termination_a=cls.interfaces[1], termination_b=circuittermination, status=connected)
+        Cable.objects.create(termination_a=cls.interfaces[2], termination_b=rearport, status=connected)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_queryset_to_csv(self):
+        """This view has a custom queryset_to_csv() implementation."""
+        response = self.client.get("{}?export".format(self._get_url("list")))
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get("Content-Type"), "text/csv")
+        self.assertEqual(
+            """\
+device_a,interface_a,device_b,interface_b,reachable
+Device 1,Interface 1,Device 2,Interface 1,True
+Device 1,Interface 2,,,True
+Device 1,Interface 3,,,False""",
+            response.content.decode(response.charset),
+        )
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+    def test_list_objects_with_constrained_permission(self):
+        """
+        Extend base GetObjectViewTestCase to have correct permissions for *both ends* of a connection.
+        """
+        instance1 = self._get_queryset().all()[0]
+
+        # Add object-level permission for the remote end of this connection as well.
+        endpoint = instance1.connected_endpoint
+        obj_perm = ObjectPermission(
+            name="Endpoint test permission",
+            constraints={"pk": endpoint.pk},
+            actions=["view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(endpoint))
+
+        # super().test_list_objects_with_constrained_permission will add permissions for instance1 itself.
+        super().test_list_objects_with_constrained_permission()
+
+
 class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualChassis