diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 3d7505f2d..c64ca47c3 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -235,6 +235,7 @@ PATCH) to maintain backward compatibility. This behavior will be discontinued be * [#3664](https://github.com/digitalocean/netbox/issues/3664) - Enable applying configuration contexts by tags * [#3706](https://github.com/digitalocean/netbox/issues/3706) - Increase `available_power` maximum value on PowerFeed * [#3731](https://github.com/digitalocean/netbox/issues/3731) - Change Graph.type to a ContentType foreign key field +* [#3801](https://github.com/digitalocean/netbox/issues/3801) - Use YAML for export of device types ## Bug Fixes (From Beta) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 18b0af525..c51f83c35 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2,6 +2,7 @@ from collections import OrderedDict 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.fields import ColorField 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 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 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/tests/test_views.py b/netbox/dcim/tests/test_views.py index 87f7b8489..856862a3e 100644 --- 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 @@ class DeviceTypeTestCase(TestCase): 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() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 779633ef8..e41d44d95 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2056,7 +2056,8 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): 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 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): 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 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): 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 index 0b4dc6f35..5cb81c6e6 100644 --- 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 @@ class ObjectListView(View): 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 @@ class ObjectListView(View): ) ) + # 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