Repository: digitalocean/pynetbox
Pull request #280 (2020-08-01 01:00:21)

Added a trace property to the traceable interface classes
I have this in a working state here, but the code is not solid enough I would feel happy about merging it. More I would like your feedback on how you would like to approach this. The main issue is the `response_loader` function needing to be able to handle multiple `return_obj` types since the trace endpoint returns multiple types of items from the api - cables, interfaces, front ports, console ports, console server ports, etc.

I saw some places where you already inspected the uri to determine object type so that is what I've done here.. I'm not married to it though. Thanks for all the work on this project! I love the module, it's been a huge help :)


Patch:
diff --git a/pynetbox/models/dcim.py b/pynetbox/models/dcim.py
index cae761c..792a457 100644
--- a/pynetbox/models/dcim.py
+++ b/pynetbox/models/dcim.py
@@ -13,12 +13,57 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 """
+from six.moves.urllib.parse import urlsplit
+
+from pynetbox.core.query import Request
 from pynetbox.core.response import Record, JsonField
 from pynetbox.core.endpoint import RODetailEndpoint
 from pynetbox.models.ipam import IpAddresses
 from pynetbox.models.circuits import Circuits
 
 
+class TraceableRecord(Record):
+    def trace(self):
+        req = Request(
+            key=str(self.id) + "/trace",
+            base=self.endpoint.url,
+            token=self.api.token,
+            session_key=self.api.session_key,
+            http_session=self.api.http_session,
+        )
+        uri_to_obj_class_map = {
+            "dcim/cables": Cables,
+            "dcim/front-ports": FrontPorts,
+            "dcim/interfaces": Interfaces,
+            "dcim/rear-ports": RearPorts,
+        }
+        ret = []
+        for (termination_a_data, cable_data, termination_b_data) in req.get():
+            this_hop_ret = []
+            for hop_item_data in (termination_a_data, cable_data, termination_b_data):
+                # if not fully terminated then some items will be None
+                if not hop_item_data:
+                    this_hop_ret.append(hop_item_data)
+                    continue
+
+                # TODO: Move this to a more general function.
+                app_endpoint = "/".join(
+                    urlsplit(hop_item_data["url"][len(self.api.base_url) :]).path.split(
+                        "/"
+                    )[1:3]
+                )
+
+                return_obj_class = uri_to_obj_class_map.get(app_endpoint, Record,)
+
+                this_hop_ret.append(
+                    return_obj_class(hop_item_data, self.endpoint.api, self.endpoint)
+                )
+
+            ret.append(this_hop_ret)
+
+        return ret
+
+
 class DeviceTypes(Record):
     def __str__(self):
         return self.model
@@ -80,11 +125,27 @@ class ConnectedEndpoint(Record):
     device = Devices
 
 
-class Interfaces(Record):
+class Interfaces(TraceableRecord):
     interface_connection = InterfaceConnection
     connected_endpoint = ConnectedEndpoint
 
 
+class PowerOutlets(TraceableRecord):
+    device = Devices
+
+
+class PowerPorts(TraceableRecord):
+    device = Devices
+
+
+class ConsolePorts(TraceableRecord):
+    device = Devices
+
+
+class ConsoleServerPorts(TraceableRecord):
+    device = Devices
+
+
 class RackReservations(Record):
     def __str__(self):
         return self.description
@@ -99,6 +160,14 @@ class RUs(Record):
     device = Devices
 
 
+class FrontPorts(Record):
+    device = Devices
+
+
+class RearPorts(Record):
+    device = Devices
+
+
 class Racks(Record):
     @property
     def units(self):
@@ -154,7 +223,14 @@ class Termination(Record):
 
 class Cables(Record):
     def __str__(self):
-        return "{} <> {}".format(self.termination_a, self.termination_b)
+        if all(
+            [
+                isinstance(i, Termination)
+                for i in (self.termination_a, self.termination_b)
+            ]
+        ):
+            return "{} <> {}".format(self.termination_a, self.termination_b)
+        return "Cable #{}".format(self.id)
 
     termination_a = Termination
     termination_b = Termination


Test patch:
diff --git a/tests/fixtures/dcim/interface_trace.json b/tests/fixtures/dcim/interface_trace.json
new file mode 100644
index 0000000..c7fd0ae
--- /dev/null
+++ b/tests/fixtures/dcim/interface_trace.json
@@ -0,0 +1,118 @@
+[
+    [
+        {
+            "id": 39126,
+            "url": "http://localhost:8000/api/dcim/interfaces/39126/",
+            "device": {
+                "id": 4747,
+                "url": "http://localhost:8000/api/dcim/devices/4747/",
+                "name": "test1-core1",
+                "display_name": "test1-core1"
+            },
+            "name": "em1",
+            "cable": 9911,
+            "connection_status": {
+                "value": false,
+                "label": "Not Connected"
+            }
+        },
+        {
+            "id": 9911,
+            "url": "http://localhost:8000/api/dcim/cables/9911/",
+            "type": "",
+            "status": "planned",
+            "label": "",
+            "color": "",
+            "length": null,
+            "length_unit": ""
+        },
+        {
+            "id": 5583,
+            "url": "http://localhost:8000/api/dcim/front-ports/5583/",
+            "device": {
+                "id": 4430,
+                "url": "http://localhost:8000/api/dcim/devices/4430/",
+                "name": "test1-patchpanel1",
+                "display_name": "test1-patchpanel1"
+            },
+            "name": "pair-11 (ports 21-22)",
+            "cable": 9911
+        }
+    ],
+    [
+        {
+            "id": 3736,
+            "url": "http://localhost:8000/api/dcim/rear-ports/3736/",
+            "device": {
+                "id": 4430,
+                "url": "http://localhost:8000/api/dcim/devices/4430/",
+                "name": "test1-patchpanel1",
+                "display_name": "test1-patchpanel1"
+            },
+            "name": "port-2",
+            "cable": 9229
+        },
+        {
+            "id": 9229,
+            "url": "http://localhost:8000/api/dcim/cables/9229/",
+            "type": "mmf-om4",
+            "status": "planned",
+            "label": "",
+            "color": "",
+            "length": null,
+            "length_unit": ""
+        },
+        {
+            "id": 3768,
+            "url": "http://localhost:8000/api/dcim/rear-ports/3768/",
+            "device": {
+                "id": 4436,
+                "url": "http://localhost:8000/api/dcim/devices/4436/",
+                "name": "test1-patchpanel2",
+                "display_name": "test1-patchpanel2"
+            },
+            "name": "port-2",
+            "cable": 9229
+        }
+    ],
+    [
+        {
+            "id": 5655,
+            "url": "http://localhost:8000/api/dcim/front-ports/5655/",
+            "device": {
+                "id": 4436,
+                "url": "http://localhost:8000/api/dcim/devices/4436/",
+                "name": "test1-patchpanel2",
+                "display_name": "test1-patchpanel2"
+            },
+            "name": "pair-11 (ports 21-22)",
+            "cable": 9240
+        },
+        {
+            "id": 9240,
+            "url": "http://localhost:8000/api/dcim/cables/9240/",
+            "type": "mmf-om4",
+            "status": "planned",
+            "label": "",
+            "color": "",
+            "length": null,
+            "length_unit": ""
+        },
+        {
+            "id": 35473,
+            "url": "http://localhost:8000/api/dcim/interfaces/35473/",
+            "device": {
+                "id": 3930,
+                "url": "http://localhost:8000/api/dcim/devices/3930/",
+                "name": "test1-core2",
+                "display_name": "test1-core2"
+            },
+            "name": "Ethernet11",
+            "cable": 9240,
+            "connection_status": {
+                "value": false,
+                "label": "Not Connected"
+            }
+        }
+    ]
+]
diff --git a/tests/test_dcim.py b/tests/test_dcim.py
index 3403f93..00b9de3 100644
--- a/tests/test_dcim.py
+++ b/tests/test_dcim.py
@@ -325,6 +325,20 @@ class InterfaceTestCase(Generic.Tests):
             headers=HEADERS,
         )
 
+    @patch(
+        "pynetbox.core.query.requests.sessions.Session.get",
+        side_effect=[
+            Response(fixture="dcim/interface.json"),
+            Response(fixture="dcim/interface_trace.json"),
+        ],
+    )
+    def test_trace(self, mock):
+        ret = nb.interfaces.get(1)
+        trace = ret.trace()
+        self.assertTrue(len(trace) == 3)
+        for hop in trace:
+            self.assertTrue(len(hop) == 3)
+
 
 class RackTestCase(Generic.Tests):
     name = "racks"