Repository: netbox-community/netbox

Issue:
Automate export of DeviceTypes in YAML export
### Environment
* Python version: 3.6.8
* NetBox version: 2.7-beta1

### Proposed Functionality
Change the built-in DeviceType export format from CSV to YAML, with each DeviceType instance represented as a discrete YAML document

### Use Case
Now that YAML-based import is supported, it makes sense to export DeviceTypes as YAML rather than CSV. This should also help people more easily contribute to the community [device type library](https://github.com/netbox-community/devicetype-library).

### Database Changes
None

### External Dependencies
None


Discussion:

Resolving patch:
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -2,6 +2,7 @@
 from itertools import count, groupby
 
 import svgwrite
+import yaml
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
@@ -25,15 +26,14 @@
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
 from utilities.utils import foreground_color, to_meters
-from .device_components import (
-    CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
-    PowerPort, RearPort,
-)
 from .device_component_templates import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
     PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
-
+from .device_components import (
+    CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
+    PowerPort, RearPort,
+)
 
 __all__ = (
     'Cable',
@@ -1003,17 +1003,92 @@ def __init__(self, *args, **kwargs):
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.manufacturer.name,
-            self.model,
-            self.slug,
-            self.part_number,
-            self.u_height,
-            self.is_full_depth,
-            self.get_subdevice_role_display(),
-            self.comments,
-        )
+    def to_yaml(self):
+        data = OrderedDict((
+            ('manufacturer', self.manufacturer.name),
+            ('model', self.model),
+            ('slug', self.slug),
+            ('part_number', self.part_number),
+            ('u_height', self.u_height),
+            ('is_full_depth', self.is_full_depth),
+            ('subdevice_role', self.subdevice_role),
+            ('comments', self.comments),
+        ))
+
+        # Component templates
+        if self.consoleport_templates.exists():
+            data['console-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                }
+                for c in self.consoleport_templates.all()
+            ]
+        if self.consoleserverport_templates.exists():
+            data['console-server-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                }
+                for c in self.consoleserverport_templates.all()
+            ]
+        if self.powerport_templates.exists():
+            data['power-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'maximum_draw': c.maximum_draw,
+                    'allocated_draw': c.allocated_draw,
+                }
+                for c in self.powerport_templates.all()
+            ]
+        if self.poweroutlet_templates.exists():
+            data['power-outlets'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'power_port': c.power_port.name if c.power_port else None,
+                    'feed_leg': c.feed_leg,
+                }
+                for c in self.poweroutlet_templates.all()
+            ]
+        if self.interface_templates.exists():
+            data['interfaces'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'mgmt_only': c.mgmt_only,
+                }
+                for c in self.interface_templates.all()
+            ]
+        if self.frontport_templates.exists():
+            data['front-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'rear_port': c.rear_port.name,
+                    'rear_port_position': c.rear_port_position,
+                }
+                for c in self.frontport_templates.all()
+            ]
+        if self.rearport_templates.exists():
+            data['rear-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'positions': c.positions,
+                }
+                for c in self.rearport_templates.all()
+            ]
+        if self.device_bay_templates.exists():
+            data['device-bays'] = [
+                {
+                    'name': c.name,
+                }
+                for c in self.device_bay_templates.all()
+            ]
+
+        return yaml.dump(dict(data), sort_keys=False)
 
     def clean(self):
 
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -2056,7 +2056,8 @@ def queryset_to_csv(self):
                 obj.get_connection_status_display(),
             ])
             csv_data.append(csv)
-        return csv_data
+
+        return '\n'.join(csv_data)
 
 
 class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
@@ -2087,7 +2088,8 @@ def queryset_to_csv(self):
                 obj.get_connection_status_display(),
             ])
             csv_data.append(csv)
-        return csv_data
+
+        return '\n'.join(csv_data)
 
 
 class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
@@ -2126,7 +2128,8 @@ def queryset_to_csv(self):
                 obj.get_connection_status_display(),
             ])
             csv_data.append(csv)
-        return csv_data
+
+        return '\n'.join(csv_data)
 
 
 #
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -75,6 +75,14 @@ class ObjectListView(View):
     table = None
     template_name = None
 
+    def queryset_to_yaml(self):
+        """
+        Export the queryset of objects as concatenated YAML documents.
+        """
+        yaml_data = [obj.to_yaml() for obj in self.queryset]
+
+        return '---\n'.join(yaml_data)
+
     def queryset_to_csv(self):
         """
         Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
@@ -90,7 +98,7 @@ def queryset_to_csv(self):
             data = csv_format(obj.to_csv())
             csv_data.append(data)
 
-        return csv_data
+        return '\n'.join(csv_data)
 
     def get(self, request):
 
@@ -121,13 +129,16 @@ def get(self, request):
                     )
                 )
 
+        # Check for YAML export support
+        elif 'export' in request.GET and hasattr(model, 'to_yaml'):
+            response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
+            filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
+            response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+            return response
+
         # Fall back to built-in CSV formatting if export requested but no template specified
         elif 'export' in request.GET and hasattr(model, 'to_csv'):
-            data = self.queryset_to_csv()
-            response = HttpResponse(
-                '\n'.join(data),
-                content_type='text/csv'
-            )
+            response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
             filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
             response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
             return response


Test patch:
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1,5 +1,6 @@
 import urllib.parse
 
+import yaml
 from django.test import Client, TestCase
 from django.urls import reverse
 
@@ -327,6 +328,17 @@ def test_devicetype_list(self):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         self.assertEqual(response.status_code, 200)
 
+    def test_devicetype_export(self):
+
+        url = reverse('dcim:devicetype_list')
+
+        response = self.client.get('{}?export'.format(url))
+        self.assertEqual(response.status_code, 200)
+        data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader))
+        self.assertEqual(len(data), 3)
+        self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1')
+        self.assertEqual(data[0]['model'], 'Device Type 1')
+
     def test_devicetype(self):
 
         devicetype = DeviceType.objects.first()