From f8fdca49680fe0de8b635628a2149d2920fb1f28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Sep 2019 17:23:56 -0400 Subject: [PATCH 01/17] Initial work on JSON/YAML-based DeviceType import --- netbox/dcim/forms.py | 33 +++++++++- netbox/dcim/urls.py | 3 +- netbox/dcim/views.py | 10 ++- netbox/templates/dcim/device_import.html | 2 +- .../templates/dcim/device_import_child.html | 2 +- netbox/templates/secrets/secret_import.html | 2 +- .../templates/utilities/obj_bulk_import.html | 60 ++++++++++++++++++ netbox/templates/utilities/obj_import.html | 30 +-------- netbox/utilities/forms.py | 37 ++++++++++- netbox/utilities/views.py | 62 ++++++++++++++++++- 10 files changed, 202 insertions(+), 39 deletions(-) create mode 100644 netbox/templates/utilities/obj_bulk_import.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4abbcdd71..08aaf02b0 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -22,7 +22,8 @@ from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, - SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES + SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, MultiObjectField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .constants import * @@ -831,6 +832,36 @@ class DeviceTypeCSVForm(forms.ModelForm): } +class InterfaceTemplateImportForm(BootstrapMixin, forms.ModelForm): + name_pattern = ExpandableNameField( + label='Name' + ) + + class Meta: + model = InterfaceTemplate + fields = [ + 'type', 'mgmt_only', + ] + + +class DeviceTypeImportForm(forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + interfaces = MultiObjectField( + form=InterfaceTemplateImportForm, + required=False + ) + + class Meta: + model = DeviceType + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'interfaces', + ] + + class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceType.objects.all(), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ae1f05757..a9d24dcb1 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -82,7 +82,8 @@ urlpatterns = [ # Device types path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), - path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), + # path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), + path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path(r'device-types//', views.DeviceTypeView.as_view(), name='devicetype'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fe98c93f3..f78ce700b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -13,6 +13,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe +from django.utils.text import slugify from django.views.generic import View from circuits.models import Circuit @@ -25,7 +26,7 @@ from utilities.paginator import EnhancedPaginator from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, - ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -654,6 +655,13 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:devicetype_list' +class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): + permission_required = 'dcim.add_devicetype' + model = DeviceType + model_form = forms.DeviceTypeImportForm + default_return_url = 'dcim:devicetype_import' + + class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_devicetype' model_form = forms.DeviceTypeCSVForm diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index 85ebfbbc6..2f3a0ea8f 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_import.html' %} +{% extends 'utilities/obj_bulk_import.html' %} {% block tabs %} {% include 'dcim/inc/device_import_header.html' %} diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html index 406d239d7..346196382 100644 --- a/netbox/templates/dcim/device_import_child.html +++ b/netbox/templates/dcim/device_import_child.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_import.html' %} +{% extends 'utilities/obj_bulk_import.html' %} {% block tabs %} {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 169f16b11..bf2f06ae9 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_import.html' %} +{% extends 'utilities/obj_bulk_import.html' %} {% load static %} {% block content %} diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/utilities/obj_bulk_import.html new file mode 100644 index 000000000..97b093a02 --- /dev/null +++ b/netbox/templates/utilities/obj_bulk_import.html @@ -0,0 +1,60 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block content %} +

{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

+{% block tabs %}{% endblock %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+ {% csrf_token %} + {% render_form form %} +
+
+ + {% if return_url %} + Cancel + {% endif %} +
+
+
+
+
+ {% if fields %} +

CSV Format

+ + + + + + + {% for name, field in fields.items %} + + + + + + {% endfor %} +
FieldRequiredDescription
{{ name }}{% if field.required %}{% endif %} + {{ field.help_text|default:field.label }} + {% if field.choices %} +
Choices: {{ field|example_choices }} + {% elif field|widget_type == 'dateinput' %} +
Format: YYYY-MM-DD + {% elif field|widget_type == 'checkboxinput' %} +
Specify "true" or "false" + {% endif %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 89621a3c3..d2da2bc94 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -6,7 +6,7 @@

{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}

{% block tabs %}{% endblock %}
-
+
{% if form.non_field_errors %}
Errors
@@ -28,33 +28,5 @@
-
- {% if fields %} -

CSV Format

- - - - - - - {% for name, field in fields.items %} - - - - - - {% endfor %} -
FieldRequiredDescription
{{ name }}{% if field.required %}{% endif %} - {{ field.help_text|default:field.label }} - {% if field.choices %} -
Choices: {{ field|example_choices }} - {% elif field|widget_type == 'dateinput' %} -
Format: YYYY-MM-DD - {% elif field|widget_type == 'checkboxinput' %} -
Specify "true" or "false" - {% endif %} -
- {% endif %} -
{% endblock %} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1d4671bf6..a083e968a 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -6,8 +6,7 @@ from io import StringIO from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput -from django.db.models import Count -from django.urls import reverse_lazy +from django.utils.html import mark_safe from mptt.forms import TreeNodeMultipleChoiceField from .constants import * @@ -554,6 +553,24 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source +class MultiObjectField(forms.Field): + """ + Use this field to relay data to another form for validation. Useful when importing data via JSON/YAML. + """ + def __init__(self, form, *args, **kwargs): + self.form = form + super().__init__(*args, **kwargs) + + def clean(self, value): + + for obj in value: + subform = self.form(obj) + if not subform.is_valid(): + raise forms.ValidationError(mark_safe(subform.errors.items())) + + return value + + class FilterChoiceIterator(forms.models.ModelChoiceIterator): def __iter__(self): @@ -721,3 +738,19 @@ class BulkEditForm(forms.Form): # Copy any nullable fields defined in Meta if hasattr(self.Meta, 'nullable_fields'): self.nullable_fields = self.Meta.nullable_fields + + +class ImportForm(BootstrapMixin, forms.Form): + """ + Generic form for creating an object from JSON/YAML data + """ + data = forms.CharField( + widget=forms.Textarea + ) + format = forms.ChoiceField( + choices=( + ('json', 'JSON'), + ('yaml', 'YAML') + ), + initial='yaml' + ) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index bbc58f134..179d47820 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,4 +1,6 @@ +import json import sys +import yaml from copy import deepcopy from django.conf import settings @@ -26,7 +28,7 @@ from extras.querysets import CustomFieldQueryset from utilities.forms import BootstrapMixin, CSVDataField from utilities.utils import csv_format from .error_handlers import handle_protectederror -from .forms import ConfirmationForm +from .forms import ConfirmationForm, ImportForm from .paginator import EnhancedPaginator @@ -393,6 +395,62 @@ class BulkCreateView(GetReturnURLMixin, View): }) +class ObjectImportView(GetReturnURLMixin, View): + """ + Import a single object (YAML or JSON format). + """ + model = None + model_form = None + template_name = 'utilities/obj_import.html' + + def create_object(self, data): + raise NotImplementedError("View must implement object creation logic") + + def get(self, request): + + form = ImportForm() + + return render(request, self.template_name, { + 'form': form, + 'obj_type': self.model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + def post(self, request): + + form = ImportForm(request.POST) + + if form.is_valid(): + + # Process object data + if form.cleaned_data['format'] == 'json': + data = json.loads(form.cleaned_data['data']) + else: + data = yaml.load(form.cleaned_data['data']) + + # Initialize model form + model_form = self.model_form(data) + + if model_form.is_valid(): + + obj = model_form.save(commit=False) + # assert False, model_form.cleaned_data['interfaces'] + + messages.success(request, "Imported object: {}".format(obj)) + return redirect(self.get_return_url(request)) + + else: + # Replicate model form errors for display + for field, err in model_form.errors.items(): + form.add_error(None, "{}: {}".format(field, err)) + + return render(request, self.template_name, { + 'form': form, + 'obj_type': self.model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + class BulkImportView(GetReturnURLMixin, View): """ Import objects in bulk (CSV format). @@ -404,7 +462,7 @@ class BulkImportView(GetReturnURLMixin, View): """ model_form = None table = None - template_name = 'utilities/obj_import.html' + template_name = 'utilities/obj_bulk_import.html' widget_attrs = {} def _import_form(self, *args, **kwargs): From 60b70b6c7b1f55cf5a65ccd8c3af9b5d3533ce89 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Sep 2019 15:16:14 -0400 Subject: [PATCH 02/17] Add RearPortTemplate power_port field --- netbox/dcim/forms.py | 5 +++++ netbox/utilities/views.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9f10cef2d..df289d32f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -919,6 +919,11 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class FrontPortTemplateImportForm(ComponentTemplateImportForm): + power_port = forms.ModelChoiceField( + queryset=RearPortTemplate.objects.all(), + to_field_name='name', + required=False + ) class Meta: model = FrontPortTemplate diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a7a308b09..06267392a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -437,7 +437,9 @@ class ObjectImportView(GetReturnURLMixin, View): with transaction.atomic(): obj = model_form.save() - messages.success(request, "Imported object: {}".format(obj)) + messages.success(request, mark_safe('Imported object: {}'.format( + obj.get_absolute_url(), obj + ))) return redirect(self.get_return_url(request)) else: From 5049c6c0a1a91641f04a50e7bcec9b3b9678a743 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Sep 2019 15:57:44 -0400 Subject: [PATCH 03/17] Add test for DeviceType import --- netbox/dcim/forms.py | 9 ++-- netbox/dcim/tests/test_forms.py | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index df289d32f..02bc61857 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -966,14 +966,14 @@ class DeviceTypeImportForm(forms.ModelForm): form=InterfaceTemplateImportForm, required=False ) - front_ports = MultiObjectField( - form=FrontPortTemplateImportForm, - required=False - ) rear_ports = MultiObjectField( form=RearPortTemplateImportForm, required=False ) + front_ports = MultiObjectField( + form=FrontPortTemplateImportForm, + required=False + ) class Meta: model = DeviceType @@ -994,7 +994,6 @@ class DeviceTypeImportForm(forms.ModelForm): data.update({ 'device_type': instance.pk }) - print(data) form = field.form(data) if form.is_valid(): form.save() diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 2f333ea69..c0b94541a 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -8,6 +8,100 @@ def get_id(model, slug): return model.objects.get(slug=slug).id +DEVICETYPE_DATA = { + 'manufacturer': 'Generic', + 'model': 'TEST-1000', + 'slug': 'test-1000', + 'u_height': 2, + 'console_ports': [ + {'name': 'Console Port 1'}, + {'name': 'Console Port 2'}, + {'name': 'Console Port 3'}, + ], + 'console_server_ports': [ + {'name': 'Console Server Port 1'}, + {'name': 'Console Server Port 2'}, + {'name': 'Console Server Port 3'}, + ], + 'power_ports': [ + {'name': 'Power Port 1'}, + {'name': 'Power Port 2'}, + {'name': 'Power Port 3'}, + ], + 'power_outlets': [ + {'name': 'Power Outlet 1', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, + {'name': 'Power Outlet 2', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, + {'name': 'Power Outlet 3', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, + ], + 'interfaces': [ + {'name': 'Interface 1', 'type': IFACE_TYPE_1GE_FIXED, 'mgmt_only': True}, + {'name': 'Interface 2', 'type': IFACE_TYPE_1GE_FIXED}, + {'name': 'Interface 3', 'type': IFACE_TYPE_1GE_FIXED}, + ], + 'rear_ports': [ + {'name': 'Rear Port 1', 'type': PORT_TYPE_8P8C}, + {'name': 'Rear Port 2', 'type': PORT_TYPE_8P8C}, + {'name': 'Rear Port 3', 'type': PORT_TYPE_8P8C}, + ], + 'front_ports': [ + {'name': 'Front Port 1', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 1'}, + {'name': 'Front Port 2', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 2'}, + {'name': 'Front Port 3', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 3'}, + ] +} + + +class DeviceTypeImportTestCase(TestCase): + + def setUp(self): + + Manufacturer(name='Generic', slug='generic').save() + + def test_import_devicetype_yaml(self): + + form = DeviceTypeImportForm(DEVICETYPE_DATA) + + self.assertTrue(form.is_valid(), "Form validation failed: {}".format(form.errors)) + + form.save() + dt = DeviceType.objects.get(model='TEST-1000') + + # Verify all of the components were created + self.assertEqual(dt.consoleport_templates.count(), 3) + cp1 = ConsolePortTemplate.objects.first() + self.assertEqual(cp1.name, 'Console Port 1') + + self.assertEqual(dt.consoleserverport_templates.count(), 3) + csp1 = ConsoleServerPortTemplate.objects.first() + self.assertEqual(csp1.name, 'Console Server Port 1') + + self.assertEqual(dt.powerport_templates.count(), 3) + pp1 = PowerPortTemplate.objects.first() + self.assertEqual(pp1.name, 'Power Port 1') + + self.assertEqual(dt.poweroutlet_templates.count(), 3) + po1 = PowerOutletTemplate.objects.first() + self.assertEqual(po1.name, 'Power Outlet 1') + self.assertEqual(po1.power_port, pp1) + self.assertEqual(po1.feed_leg, POWERFEED_LEG_A) + + self.assertEqual(dt.interface_templates.count(), 4) + iface1 = Interface.objects.first() + self.assertEqual(iface1.name, 'Interface 1') + self.assertEqual(iface1.type, IFACE_TYPE_1GE_FIXED) + self.assertTrue(iface1.mgmt_only) + + self.assertEqual(dt.rearport_templates.count(), 3) + rp1 = FrontPortTemplate.objects.first() + self.assertEqual(rp1.name, 'Rear Port 1') + + self.assertEqual(dt.frontport_templates.count(), 3) + fp1 = FrontPortTemplate.objects.first() + self.assertEqual(fp1.name, 'Front Port 1') + self.assertEqual(fp1.rear_port, rp1) + self.assertEqual(fp1.rear_port_position, 1) + + class DeviceTestCase(TestCase): fixtures = ['dcim', 'ipam'] From 15b2a7eab0612ab43eda6f17f37b4eabe4b34097 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Sep 2019 15:58:23 -0400 Subject: [PATCH 04/17] Fix form rendering; enable toggling of redirect to imported object --- netbox/dcim/forms.py | 2 +- netbox/templates/utilities/obj_import.html | 5 +++-- netbox/utilities/views.py | 10 +++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 02bc61857..1af04a567 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -941,7 +941,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): ] -class DeviceTypeImportForm(forms.ModelForm): +class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name' diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index d2da2bc94..d0ba99295 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -15,12 +15,13 @@
{% endif %} -
+ {% csrf_token %} {% render_form form %}
- + + {% if return_url %} Cancel {% endif %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 06267392a..3a9b19c76 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -440,7 +440,15 @@ class ObjectImportView(GetReturnURLMixin, View): messages.success(request, mark_safe('Imported object: {}'.format( obj.get_absolute_url(), obj ))) - return redirect(self.get_return_url(request)) + + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + return redirect(return_url) + else: + return redirect(self.get_return_url(request, obj)) else: # Replicate model form errors for display From 2621f17cdef78d70d8ab1b1ddbba468eed25da92 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Sep 2019 16:03:05 -0400 Subject: [PATCH 05/17] Remove legacy CSV-based DeviceType import --- netbox/dcim/forms.py | 25 ------------------------- netbox/dcim/urls.py | 1 - netbox/dcim/views.py | 7 ------- 3 files changed, 33 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1af04a567..9f465414d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -829,31 +829,6 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): } -class DeviceTypeCSVForm(forms.ModelForm): - manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), - required=True, - to_field_name='name', - help_text='Manufacturer name', - error_messages={ - 'invalid_choice': 'Manufacturer not found.', - } - ) - subdevice_role = CSVChoiceField( - choices=SUBDEVICE_ROLE_CHOICES, - required=False, - help_text='Parent/child status' - ) - - class Meta: - model = DeviceType - fields = DeviceType.csv_headers - help_texts = { - 'model': 'Model name', - 'slug': 'URL-friendly slug', - } - - class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): def clean_device_type(self): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index d7ba0cb0e..8c5a72727 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -82,7 +82,6 @@ urlpatterns = [ # Device types path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), - # path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7bd8868f3..56119a775 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -663,13 +663,6 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): default_return_url = 'dcim:devicetype_import' -class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_devicetype' - model_form = forms.DeviceTypeCSVForm - table = tables.DeviceTypeTable - default_return_url = 'dcim:devicetype_list' - - class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) From 30ee232654bc7d9220f92c74fda9f15d12802059 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Sep 2019 16:13:52 -0400 Subject: [PATCH 06/17] Move JSON/YAML data valdiation to ImportForm --- netbox/utilities/forms.py | 26 ++++++++++++++++++++++++-- netbox/utilities/views.py | 10 +--------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index af4e92dd0..dae9abda3 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -2,11 +2,11 @@ import csv import json import re from io import StringIO +import yaml from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput -from django.utils.html import mark_safe from mptt.forms import TreeNodeMultipleChoiceField from .constants import * @@ -747,7 +747,8 @@ class ImportForm(BootstrapMixin, forms.Form): Generic form for creating an object from JSON/YAML data """ data = forms.CharField( - widget=forms.Textarea + widget=forms.Textarea, + help_text="Enter object data in JSON or YAML format." ) format = forms.ChoiceField( choices=( @@ -756,3 +757,24 @@ class ImportForm(BootstrapMixin, forms.Form): ), initial='yaml' ) + + def clean(self): + + data = self.cleaned_data['data'] + format = self.cleaned_data['format'] + + # Process JSON/YAML data + if format == 'json': + try: + self.cleaned_data['data'] = json.loads(data) + except json.decoder.JSONDecodeError as err: + raise forms.ValidationError({ + 'data': "Invalid JSON data: {}".format(err) + }) + else: + try: + self.cleaned_data['data'] = yaml.load(data) + except yaml.scanner.ScannerError as err: + raise forms.ValidationError({ + 'data': "Invalid YAML data: {}".format(err) + }) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 3a9b19c76..b5406e145 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -420,18 +420,10 @@ class ObjectImportView(GetReturnURLMixin, View): def post(self, request): form = ImportForm(request.POST) - if form.is_valid(): - # Process object data - if form.cleaned_data['format'] == 'json': - data = json.loads(form.cleaned_data['data']) - else: - data = yaml.load(form.cleaned_data['data']) - # Initialize model form - model_form = self.model_form(data) - + model_form = self.model_form(form.cleaned_data['data']) if model_form.is_valid(): with transaction.atomic(): From 0615d368f2b3d0077a6f335d90adcb063414ad3c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Sep 2019 16:51:59 -0400 Subject: [PATCH 07/17] Force validation of individual objects within a MultiObjectField --- netbox/dcim/forms.py | 21 ++++++++++----------- netbox/utilities/forms.py | 8 ++++++++ netbox/utilities/views.py | 9 +++++++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9f465414d..6c145e9fa 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -848,7 +848,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', + 'name', ] @@ -857,7 +857,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', + 'name', ] @@ -866,7 +866,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'maximum_draw', 'allocated_draw', + 'name', 'maximum_draw', 'allocated_draw', ] @@ -880,7 +880,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'power_port', 'feed_leg', + 'name', 'power_port', 'feed_leg', ] @@ -889,7 +889,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'type', 'mgmt_only', + 'name', 'type', 'mgmt_only', ] @@ -903,7 +903,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + 'name', 'type', 'rear_port', 'rear_port_position', ] @@ -912,7 +912,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', + 'name', 'type', 'positions', ] @@ -966,12 +966,11 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): for field_name, field in self.fields.items(): if isinstance(field, MultiObjectField): for data in self.cleaned_data[field_name]: - data.update({ - 'device_type': instance.pk - }) form = field.form(data) if form.is_valid(): - form.save() + component = form.save(commit=False) + component.device_type = instance + component.save() return instance diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index dae9abda3..10390ca5e 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -570,6 +570,14 @@ class MultiObjectField(forms.Field): if value is None: return list() + for i, obj_data in enumerate(value, start=1): + form = self.form(obj_data) + if not form.is_valid(): + errors = [ + "Object {} {}: {}".format(i, field, errors) for field, errors in form.errors.items() + ] + raise forms.ValidationError(errors) + return value diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index b5406e145..fe39263b1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -443,9 +443,14 @@ class ObjectImportView(GetReturnURLMixin, View): return redirect(self.get_return_url(request, obj)) else: + # Replicate model form errors for display - for field, err in model_form.errors.items(): - form.add_error(None, "{}: {}".format(field, err)) + for field, errors in model_form.errors.items(): + for err in errors: + if field == '__all__': + form.add_error(None, err) + else: + form.add_error(None, "{}: {}".format(field, err)) return render(request, self.template_name, { 'form': form, From 47f1febfc96a54c543ba58b507caa6bfaebe66e6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 25 Sep 2019 16:06:09 -0400 Subject: [PATCH 08/17] Capture import form field default values --- netbox/utilities/views.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index fe39263b1..b46a50ef3 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -423,7 +423,17 @@ class ObjectImportView(GetReturnURLMixin, View): if form.is_valid(): # Initialize model form - model_form = self.model_form(form.cleaned_data['data']) + data = form.cleaned_data['data'] + model_form = self.model_form(data) + + # Assign default values for any fields which were not specified. We have to do this manually because passing + # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not + # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the + # applicable field defaults as needed prior to form validation. + for field_name, field in model_form.fields.items(): + if field_name not in data and hasattr(field, 'initial'): + model_form.data[field_name] = field.initial + if model_form.is_valid(): with transaction.atomic(): From 5f3528cf744033a823d6bbccd3c90d0185f95ffb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 25 Sep 2019 16:19:22 -0400 Subject: [PATCH 09/17] Capture MultiObjectField default form field values --- netbox/utilities/forms.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 10390ca5e..41d577c68 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -571,7 +571,15 @@ class MultiObjectField(forms.Field): return list() for i, obj_data in enumerate(value, start=1): + + # Bind object data to form form = self.form(obj_data) + + # Assign default values for required fields that have not been defined + for field_name, field in form.fields.items(): + if field_name not in obj_data and hasattr(field, 'initial'): + form.data[field_name] = field.initial + if not form.is_valid(): errors = [ "Object {} {}: {}".format(i, field, errors) for field, errors in form.errors.items() From 36d4f0d259920cd11a0877877d9ef5f794379065 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 25 Sep 2019 16:39:04 -0400 Subject: [PATCH 10/17] Fix typo --- netbox/dcim/tests/test_forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index c0b94541a..aed7ee9aa 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -92,7 +92,7 @@ class DeviceTypeImportTestCase(TestCase): self.assertTrue(iface1.mgmt_only) self.assertEqual(dt.rearport_templates.count(), 3) - rp1 = FrontPortTemplate.objects.first() + rp1 = RearPortTemplate.objects.first() self.assertEqual(rp1.name, 'Rear Port 1') self.assertEqual(dt.frontport_templates.count(), 3) From edc1b52f65286b2e2127cb9d283a4906169d68c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Sep 2019 16:51:12 -0400 Subject: [PATCH 11/17] Adopted a different approach to importing related objects --- netbox/dcim/forms.py | 246 ++++++++++++++------------------ netbox/dcim/tests/test_forms.py | 24 +++- netbox/dcim/views.py | 12 +- netbox/utilities/forms.py | 33 ----- netbox/utilities/views.py | 30 +++- 5 files changed, 167 insertions(+), 178 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c2818e267..ea663fbcc 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -24,8 +24,7 @@ from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, - SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, MultiObjectField, - BOOLEAN_WITH_BLANK_CHOICES, + SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .constants import * @@ -829,126 +828,11 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): } -class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - - def clean_device_type(self): - - data = self.cleaned_data['device_type'] - - # Limit fields referencing other components to the parent DeviceType - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and not field_name == 'device_type': - field.queryset = field.queryset.filter(device_type=data) - - return data - - -class ConsolePortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = ConsolePortTemplate - fields = [ - 'name', - ] - - -class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = ConsoleServerPortTemplate - fields = [ - 'name', - ] - - -class PowerPortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = PowerPortTemplate - fields = [ - 'name', 'maximum_draw', 'allocated_draw', - ] - - -class PowerOutletTemplateImportForm(ComponentTemplateImportForm): - power_port = forms.ModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - to_field_name='name', - required=False - ) - - class Meta: - model = PowerOutletTemplate - fields = [ - 'name', 'power_port', 'feed_leg', - ] - - -class InterfaceTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = InterfaceTemplate - fields = [ - 'name', 'type', 'mgmt_only', - ] - - -class FrontPortTemplateImportForm(ComponentTemplateImportForm): - power_port = forms.ModelChoiceField( - queryset=RearPortTemplate.objects.all(), - to_field_name='name', - required=False - ) - - class Meta: - model = FrontPortTemplate - fields = [ - 'name', 'type', 'rear_port', 'rear_port_position', - ] - - -class RearPortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = RearPortTemplate - fields = [ - 'name', 'type', 'positions', - ] - - class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name' ) - console_ports = MultiObjectField( - form=ConsolePortTemplateImportForm, - required=False - ) - console_server_ports = MultiObjectField( - form=ConsoleServerPortTemplateImportForm, - required=False - ) - power_ports = MultiObjectField( - form=PowerPortTemplateImportForm, - required=False - ) - power_outlets = MultiObjectField( - form=PowerOutletTemplateImportForm, - required=False - ) - interfaces = MultiObjectField( - form=InterfaceTemplateImportForm, - required=False - ) - rear_ports = MultiObjectField( - form=RearPortTemplateImportForm, - required=False - ) - front_ports = MultiObjectField( - form=FrontPortTemplateImportForm, - required=False - ) class Meta: model = DeviceType @@ -956,24 +840,6 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', ] - def save(self, commit=True): - - instance = super().save(commit) - - if commit: - - # Save related components - for field_name, field in self.fields.items(): - if isinstance(field, MultiObjectField): - for data in self.cleaned_data[field_name]: - form = field.form(data) - if form.is_valid(): - component = form.save(commit=False) - component.device_type = instance - component.save() - - return instance - class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1334,6 +1200,116 @@ class DeviceBayTemplateCreateForm(ComponentForm): ) +# +# Component template import forms +# + +class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): + + def __init__(self, device_type, data=None, *args, **kwargs): + + # Must pass the parent DeviceType on form initialization + data.update({ + 'device_type': device_type.pk, + }) + print(data) + + super().__init__(data, *args, **kwargs) + + def clean_device_type(self): + + data = self.cleaned_data['device_type'] + + # Limit fields referencing other components to the parent DeviceType + for field_name, field in self.fields.items(): + if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': + field.queryset = field.queryset.filter(device_type=data) + + return data + + +class ConsolePortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ConsolePortTemplate + fields = [ + 'device_type', 'name', + ] + + +class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ConsoleServerPortTemplate + fields = [ + 'device_type', 'name', + ] + + +class PowerPortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = PowerPortTemplate + fields = [ + 'device_type', 'name', 'maximum_draw', 'allocated_draw', + ] + + +class PowerOutletTemplateImportForm(ComponentTemplateImportForm): + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = PowerOutletTemplate + fields = [ + 'device_type', 'name', 'power_port', 'feed_leg', + ] + + +class InterfaceTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = InterfaceTemplate + fields = [ + 'device_type', 'name', 'type', 'mgmt_only', + ] + + +class FrontPortTemplateImportForm(ComponentTemplateImportForm): + power_port = forms.ModelChoiceField( + queryset=RearPortTemplate.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = FrontPortTemplate + fields = [ + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + ] + + +class RearPortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = RearPortTemplate + fields = [ + 'device_type', 'name', 'type', 'positions', + ] + + +class DeviceBayTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = DeviceBayTemplate + fields = [ + 'device_type', 'name', + ] + + # # Device roles # diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index aed7ee9aa..d9cf10fdb 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -13,22 +13,22 @@ DEVICETYPE_DATA = { 'model': 'TEST-1000', 'slug': 'test-1000', 'u_height': 2, - 'console_ports': [ + 'console-ports': [ {'name': 'Console Port 1'}, {'name': 'Console Port 2'}, {'name': 'Console Port 3'}, ], - 'console_server_ports': [ + 'console-server-ports': [ {'name': 'Console Server Port 1'}, {'name': 'Console Server Port 2'}, {'name': 'Console Server Port 3'}, ], - 'power_ports': [ + 'power-ports': [ {'name': 'Power Port 1'}, {'name': 'Power Port 2'}, {'name': 'Power Port 3'}, ], - 'power_outlets': [ + 'power-outlets': [ {'name': 'Power Outlet 1', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, {'name': 'Power Outlet 2', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, {'name': 'Power Outlet 3', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, @@ -38,16 +38,21 @@ DEVICETYPE_DATA = { {'name': 'Interface 2', 'type': IFACE_TYPE_1GE_FIXED}, {'name': 'Interface 3', 'type': IFACE_TYPE_1GE_FIXED}, ], - 'rear_ports': [ + 'rear-ports': [ {'name': 'Rear Port 1', 'type': PORT_TYPE_8P8C}, {'name': 'Rear Port 2', 'type': PORT_TYPE_8P8C}, {'name': 'Rear Port 3', 'type': PORT_TYPE_8P8C}, ], - 'front_ports': [ + 'front-ports': [ {'name': 'Front Port 1', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 1'}, {'name': 'Front Port 2', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 2'}, {'name': 'Front Port 3', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 3'}, - ] + ], + 'device-bays': [ + {'name': 'Device Bay 1'}, + {'name': 'Device Bay 2'}, + {'name': 'Device Bay 3'}, + ], } @@ -67,6 +72,7 @@ class DeviceTypeImportTestCase(TestCase): dt = DeviceType.objects.get(model='TEST-1000') # Verify all of the components were created + # TODO: The creation of components now occurs in the view rather than the form self.assertEqual(dt.consoleport_templates.count(), 3) cp1 = ConsolePortTemplate.objects.first() self.assertEqual(cp1.name, 'Console Port 1') @@ -101,6 +107,10 @@ class DeviceTypeImportTestCase(TestCase): self.assertEqual(fp1.rear_port, rp1) self.assertEqual(fp1.rear_port_position, 1) + self.assertEqual(dt.devicebay_templates.count(), 3) + db1 = DeviceBayTemplate.objects.first() + self.assertEqual(db1.name, 'Device Bay 1') + class DeviceTestCase(TestCase): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 56119a775..a2e162519 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,4 @@ +from collections import OrderedDict import re from django.conf import settings @@ -13,7 +14,6 @@ from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe -from django.utils.text import slugify from django.views.generic import View from circuits.models import Circuit @@ -660,6 +660,16 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): permission_required = 'dcim.add_devicetype' model = DeviceType model_form = forms.DeviceTypeImportForm + related_object_forms = OrderedDict(( + ('console-ports', forms.ConsolePortTemplateImportForm), + ('console-server-ports', forms.ConsoleServerPortTemplateImportForm), + ('power-ports', forms.PowerPortTemplateImportForm), + ('power-outlets', forms.PowerOutletTemplateImportForm), + ('interfaces', forms.InterfaceTemplateImportForm), + ('rear-ports', forms.RearPortTemplateImportForm), + ('front-ports', forms.FrontPortTemplateImportForm), + ('device-bays', forms.DeviceBayTemplateImportForm), + )) default_return_url = 'dcim:devicetype_import' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 41d577c68..9d9116fbc 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -556,39 +556,6 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class MultiObjectField(forms.Field): - """ - Use this field to relay data to another form for validation. Useful when importing data via JSON/YAML. - """ - def __init__(self, form, *args, **kwargs): - self.form = form - super().__init__(*args, **kwargs) - - def clean(self, value): - - # Value needs to be an iterable - if value is None: - return list() - - for i, obj_data in enumerate(value, start=1): - - # Bind object data to form - form = self.form(obj_data) - - # Assign default values for required fields that have not been defined - for field_name, field in form.fields.items(): - if field_name not in obj_data and hasattr(field, 'initial'): - form.data[field_name] = field.initial - - if not form.is_valid(): - errors = [ - "Object {} {}: {}".format(i, field, errors) for field, errors in form.errors.items() - ] - raise forms.ValidationError(errors) - - return value - - class FilterChoiceIterator(forms.models.ModelChoiceIterator): def __iter__(self): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index b46a50ef3..e17da7353 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -26,6 +26,7 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset +from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField from utilities.utils import csv_format from .error_handlers import handle_protectederror @@ -402,6 +403,7 @@ class ObjectImportView(GetReturnURLMixin, View): """ model = None model_form = None + related_object_forms = dict() template_name = 'utilities/obj_import.html' def create_object(self, data): @@ -436,8 +438,32 @@ class ObjectImportView(GetReturnURLMixin, View): if model_form.is_valid(): - with transaction.atomic(): - obj = model_form.save() + try: + with transaction.atomic(): + + # Save the primary object + obj = model_form.save() + + # Iterate through the related object forms (if any), validating and saving each instance. + for field, related_object_form in self.related_object_forms.items(): + + for i, rel_obj_data in enumerate(data.get(field, list())): + + f = related_object_form(obj, rel_obj_data) + if f.is_valid(): + f.save() + else: + # Replicate errors on the related object form to the primary form for display + for field_name, errors in f.errors.items(): + for err in errors: + err_msg = "{}[{}] {}: {}".format(field, i, field_name, err) + model_form.add_error(None, err_msg) + raise AbortTransaction() + + except AbortTransaction: + pass + + if not model_form.errors: messages.success(request, mark_safe('Imported object: {}'.format( obj.get_absolute_url(), obj From ee4e68b08272ad92149801ed529a48d8f26f41ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Oct 2019 16:36:31 -0400 Subject: [PATCH 12/17] Rewrote test for DeviceType import --- netbox/dcim/forms.py | 3 +- netbox/dcim/tests/test_forms.py | 104 ------------------------- netbox/dcim/tests/test_views.py | 133 +++++++++++++++++++++++++++++++- netbox/utilities/views.py | 13 +++- 4 files changed, 140 insertions(+), 113 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ea663fbcc..b54a165a0 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1212,7 +1212,6 @@ class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): data.update({ 'device_type': device_type.pk, }) - print(data) super().__init__(data, *args, **kwargs) @@ -1279,7 +1278,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class FrontPortTemplateImportForm(ComponentTemplateImportForm): - power_port = forms.ModelChoiceField( + rear_port = forms.ModelChoiceField( queryset=RearPortTemplate.objects.all(), to_field_name='name', required=False diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index d9cf10fdb..2f333ea69 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -8,110 +8,6 @@ def get_id(model, slug): return model.objects.get(slug=slug).id -DEVICETYPE_DATA = { - 'manufacturer': 'Generic', - 'model': 'TEST-1000', - 'slug': 'test-1000', - 'u_height': 2, - 'console-ports': [ - {'name': 'Console Port 1'}, - {'name': 'Console Port 2'}, - {'name': 'Console Port 3'}, - ], - 'console-server-ports': [ - {'name': 'Console Server Port 1'}, - {'name': 'Console Server Port 2'}, - {'name': 'Console Server Port 3'}, - ], - 'power-ports': [ - {'name': 'Power Port 1'}, - {'name': 'Power Port 2'}, - {'name': 'Power Port 3'}, - ], - 'power-outlets': [ - {'name': 'Power Outlet 1', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, - {'name': 'Power Outlet 2', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, - {'name': 'Power Outlet 3', 'power_port': 'Power Port 1', 'feed_leg': POWERFEED_LEG_A}, - ], - 'interfaces': [ - {'name': 'Interface 1', 'type': IFACE_TYPE_1GE_FIXED, 'mgmt_only': True}, - {'name': 'Interface 2', 'type': IFACE_TYPE_1GE_FIXED}, - {'name': 'Interface 3', 'type': IFACE_TYPE_1GE_FIXED}, - ], - 'rear-ports': [ - {'name': 'Rear Port 1', 'type': PORT_TYPE_8P8C}, - {'name': 'Rear Port 2', 'type': PORT_TYPE_8P8C}, - {'name': 'Rear Port 3', 'type': PORT_TYPE_8P8C}, - ], - 'front-ports': [ - {'name': 'Front Port 1', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 1'}, - {'name': 'Front Port 2', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 2'}, - {'name': 'Front Port 3', 'type': PORT_TYPE_8P8C, 'rear_port': 'Rear Port 3'}, - ], - 'device-bays': [ - {'name': 'Device Bay 1'}, - {'name': 'Device Bay 2'}, - {'name': 'Device Bay 3'}, - ], -} - - -class DeviceTypeImportTestCase(TestCase): - - def setUp(self): - - Manufacturer(name='Generic', slug='generic').save() - - def test_import_devicetype_yaml(self): - - form = DeviceTypeImportForm(DEVICETYPE_DATA) - - self.assertTrue(form.is_valid(), "Form validation failed: {}".format(form.errors)) - - form.save() - dt = DeviceType.objects.get(model='TEST-1000') - - # Verify all of the components were created - # TODO: The creation of components now occurs in the view rather than the form - self.assertEqual(dt.consoleport_templates.count(), 3) - cp1 = ConsolePortTemplate.objects.first() - self.assertEqual(cp1.name, 'Console Port 1') - - self.assertEqual(dt.consoleserverport_templates.count(), 3) - csp1 = ConsoleServerPortTemplate.objects.first() - self.assertEqual(csp1.name, 'Console Server Port 1') - - self.assertEqual(dt.powerport_templates.count(), 3) - pp1 = PowerPortTemplate.objects.first() - self.assertEqual(pp1.name, 'Power Port 1') - - self.assertEqual(dt.poweroutlet_templates.count(), 3) - po1 = PowerOutletTemplate.objects.first() - self.assertEqual(po1.name, 'Power Outlet 1') - self.assertEqual(po1.power_port, pp1) - self.assertEqual(po1.feed_leg, POWERFEED_LEG_A) - - self.assertEqual(dt.interface_templates.count(), 4) - iface1 = Interface.objects.first() - self.assertEqual(iface1.name, 'Interface 1') - self.assertEqual(iface1.type, IFACE_TYPE_1GE_FIXED) - self.assertTrue(iface1.mgmt_only) - - self.assertEqual(dt.rearport_templates.count(), 3) - rp1 = RearPortTemplate.objects.first() - self.assertEqual(rp1.name, 'Rear Port 1') - - self.assertEqual(dt.frontport_templates.count(), 3) - fp1 = FrontPortTemplate.objects.first() - self.assertEqual(fp1.name, 'Front Port 1') - self.assertEqual(fp1.rear_port, rp1) - self.assertEqual(fp1.rear_port_position, 1) - - self.assertEqual(dt.devicebay_templates.count(), 3) - db1 = DeviceBayTemplate.objects.first() - self.assertEqual(db1.name, 'Device Bay 1') - - class DeviceTestCase(TestCase): fixtures = ['dcim', 'ipam'] diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6e34b8ae9..6af101e4c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -3,10 +3,11 @@ import urllib.parse from django.test import Client, TestCase from django.urls import reverse -from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED +from dcim.constants import * from dcim.models import ( - Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup, - RackReservation, RackRole, Site, Region, VirtualChassis, + Cable, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, + FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerPortTemplate, + PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPortTemplate, Site, Region, VirtualChassis, ) from utilities.testing import create_test_user @@ -221,6 +222,132 @@ class DeviceTypeTestCase(TestCase): response = self.client.get(devicetype.get_absolute_url()) self.assertEqual(response.status_code, 200) + def test_devicetype_import(self): + + IMPORT_DATA = """ +manufacturer: Generic +model: TEST-1000 +slug: test-1000 +u_height: 2 +console-ports: + - name: Console Port 1 + - name: Console Port 2 + - name: Console Port 3 +console-server-ports: + - name: Console Server Port 1 + - name: Console Server Port 2 + - name: Console Server Port 3 +power-ports: + - name: Power Port 1 + - name: Power Port 2 + - name: Power Port 3 +power-outlets: + - name: Power Outlet 1 + power_port: Power Port 1 + feed_leg: 1 + - name: Power Outlet 2 + power_port: Power Port 1 + feed_leg: 1 + - name: Power Outlet 3 + power_port: Power Port 1 + feed_leg: 1 +interfaces: + - name: Interface 1 + type: 1000 + mgmt_only: true + - name: Interface 2 + type: 1000 + - name: Interface 3 + type: 1000 +rear-ports: + - name: Rear Port 1 + type: 1000 + - name: Rear Port 2 + type: 1000 + - name: Rear Port 3 + type: 1000 +front-ports: + - name: Front Port 1 + type: 1000 + rear_port: Rear Port 1 + - name: Front Port 2 + type: 1000 + rear_port: Rear Port 2 + - name: Front Port 3 + type: 1000 + rear_port: Rear Port 3 +device-bays: + - name: Device Bay 1 + - name: Device Bay 2 + - name: Device Bay 3 +""" + + # Create the manufacturer + Manufacturer(name='Generic', slug='generic').save() + + # Authenticate as user with necessary permissions + user = create_test_user(username='testuser2', permissions=[ + 'dcim.view_devicetype', + 'dcim.add_devicetype', + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + 'dcim.add_devicebaytemplate', + ]) + self.client.force_login(user) + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True) + + dt = DeviceType.objects.get(model='TEST-1000') + + # Verify all of the components were created + self.assertEqual(dt.consoleport_templates.count(), 3) + cp1 = ConsolePortTemplate.objects.first() + self.assertEqual(cp1.name, 'Console Port 1') + + self.assertEqual(dt.consoleserverport_templates.count(), 3) + csp1 = ConsoleServerPortTemplate.objects.first() + self.assertEqual(csp1.name, 'Console Server Port 1') + + self.assertEqual(dt.powerport_templates.count(), 3) + pp1 = PowerPortTemplate.objects.first() + self.assertEqual(pp1.name, 'Power Port 1') + + self.assertEqual(dt.poweroutlet_templates.count(), 3) + po1 = PowerOutletTemplate.objects.first() + self.assertEqual(po1.name, 'Power Outlet 1') + self.assertEqual(po1.power_port, pp1) + self.assertEqual(po1.feed_leg, POWERFEED_LEG_A) + + self.assertEqual(dt.interface_templates.count(), 3) + iface1 = InterfaceTemplate.objects.first() + self.assertEqual(iface1.name, 'Interface 1') + self.assertEqual(iface1.type, IFACE_TYPE_1GE_FIXED) + self.assertTrue(iface1.mgmt_only) + + self.assertEqual(dt.rearport_templates.count(), 3) + rp1 = RearPortTemplate.objects.first() + self.assertEqual(rp1.name, 'Rear Port 1') + + self.assertEqual(dt.frontport_templates.count(), 3) + fp1 = FrontPortTemplate.objects.first() + self.assertEqual(fp1.name, 'Front Port 1') + self.assertEqual(fp1.rear_port, rp1) + self.assertEqual(fp1.rear_port_position, 1) + + self.assertEqual(dt.device_bay_templates.count(), 3) + db1 = DeviceBayTemplate.objects.first() + self.assertEqual(db1.name, 'Device Bay 1') + + class DeviceRoleTestCase(TestCase): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e17da7353..f53e4bebb 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -445,18 +445,23 @@ class ObjectImportView(GetReturnURLMixin, View): obj = model_form.save() # Iterate through the related object forms (if any), validating and saving each instance. - for field, related_object_form in self.related_object_forms.items(): + for field_name, related_object_form in self.related_object_forms.items(): - for i, rel_obj_data in enumerate(data.get(field, list())): + for i, rel_obj_data in enumerate(data.get(field_name, list())): f = related_object_form(obj, rel_obj_data) + + for subfield_name, field in f.fields.items(): + if subfield_name not in rel_obj_data and hasattr(field, 'initial'): + f.data[subfield_name] = field.initial + if f.is_valid(): f.save() else: # Replicate errors on the related object form to the primary form for display - for field_name, errors in f.errors.items(): + for subfield_name, errors in f.errors.items(): for err in errors: - err_msg = "{}[{}] {}: {}".format(field, i, field_name, err) + err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) model_form.add_error(None, err_msg) raise AbortTransaction() From 88d61db384f287942d8310ad70091ce166142c40 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Oct 2019 16:39:11 -0400 Subject: [PATCH 13/17] Fix YAMLLoadWarning --- netbox/utilities/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 9d9116fbc..ee63712a0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -756,7 +756,7 @@ class ImportForm(BootstrapMixin, forms.Form): }) else: try: - self.cleaned_data['data'] = yaml.load(data) + self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) except yaml.scanner.ScannerError as err: raise forms.ValidationError({ 'data': "Invalid YAML data: {}".format(err) From 6892b79366cb2271d1f648efa2bde502e9f345ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Oct 2019 16:54:10 -0400 Subject: [PATCH 14/17] Enforce object creation permissions --- netbox/dcim/tests/test_views.py | 1 + netbox/dcim/views.py | 12 +++++++++++- netbox/utilities/views.py | 3 --- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6af101e4c..03515b680 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -305,6 +305,7 @@ device-bays: 'format': 'yaml' } response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True) + self.assertEqual(response.status_code, 200) dt = DeviceType.objects.get(model='TEST-1000') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a2e162519..7666bafd3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -657,7 +657,17 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): - permission_required = 'dcim.add_devicetype' + permission_required = [ + 'dcim.add_devicetype', + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + 'dcim.add_devicebaytemplate', + ] model = DeviceType model_form = forms.DeviceTypeImportForm related_object_forms = OrderedDict(( diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f53e4bebb..48c4f01e6 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -406,9 +406,6 @@ class ObjectImportView(GetReturnURLMixin, View): related_object_forms = dict() template_name = 'utilities/obj_import.html' - def create_object(self, data): - raise NotImplementedError("View must implement object creation logic") - def get(self, request): form = ImportForm() From 807d8496574dc0ada89468a1f91ed1e00c175970 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Oct 2019 17:07:17 -0400 Subject: [PATCH 15/17] PEP8 fix --- netbox/dcim/tests/test_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 03515b680..754a2dd83 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -349,7 +349,6 @@ device-bays: self.assertEqual(db1.name, 'Device Bay 1') - class DeviceRoleTestCase(TestCase): def setUp(self): From d787c353f3ecbaebbf39f52c801c6cf92143d220 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Oct 2019 23:24:44 -0400 Subject: [PATCH 16/17] Added slug choices for interface and port types --- netbox/dcim/constants.py | 425 ++++++++++++++++++++++++++++++-- netbox/dcim/forms.py | 24 ++ netbox/dcim/tests/test_views.py | 18 +- 3 files changed, 437 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 840d55d6b..9d20229a1 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -1,4 +1,3 @@ - # Rack types RACK_TYPE_2POST = 100 RACK_TYPE_4POST = 200 @@ -58,7 +57,10 @@ SUBDEVICE_ROLE_CHOICES = ( (SUBDEVICE_ROLE_CHILD, 'Child'), ) -# Interface types +# +# Numeric interface types +# + # Virtual IFACE_TYPE_VIRTUAL = 0 IFACE_TYPE_LAG = 200 @@ -113,15 +115,15 @@ IFACE_TYPE_16GFC_SFP_PLUS = 3160 IFACE_TYPE_32GFC_SFP28 = 3320 IFACE_TYPE_128GFC_QSFP28 = 3400 # InfiniBand -IFACE_FF_INFINIBAND_SDR = 7010 -IFACE_FF_INFINIBAND_DDR = 7020 -IFACE_FF_INFINIBAND_QDR = 7030 -IFACE_FF_INFINIBAND_FDR10 = 7040 -IFACE_FF_INFINIBAND_FDR = 7050 -IFACE_FF_INFINIBAND_EDR = 7060 -IFACE_FF_INFINIBAND_HDR = 7070 -IFACE_FF_INFINIBAND_NDR = 7080 -IFACE_FF_INFINIBAND_XDR = 7090 +IFACE_TYPE_INFINIBAND_SDR = 7010 +IFACE_TYPE_INFINIBAND_DDR = 7020 +IFACE_TYPE_INFINIBAND_QDR = 7030 +IFACE_TYPE_INFINIBAND_FDR10 = 7040 +IFACE_TYPE_INFINIBAND_FDR = 7050 +IFACE_TYPE_INFINIBAND_EDR = 7060 +IFACE_TYPE_INFINIBAND_HDR = 7070 +IFACE_TYPE_INFINIBAND_NDR = 7080 +IFACE_TYPE_INFINIBAND_XDR = 7090 # Serial IFACE_TYPE_T1 = 4000 IFACE_TYPE_E1 = 4010 @@ -227,15 +229,15 @@ IFACE_TYPE_CHOICES = [ [ 'InfiniBand', [ - [IFACE_FF_INFINIBAND_SDR, 'SDR (2 Gbps)'], - [IFACE_FF_INFINIBAND_DDR, 'DDR (4 Gbps)'], - [IFACE_FF_INFINIBAND_QDR, 'QDR (8 Gbps)'], - [IFACE_FF_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'], - [IFACE_FF_INFINIBAND_FDR, 'FDR (13.5 Gbps)'], - [IFACE_FF_INFINIBAND_EDR, 'EDR (25 Gbps)'], - [IFACE_FF_INFINIBAND_HDR, 'HDR (50 Gbps)'], - [IFACE_FF_INFINIBAND_NDR, 'NDR (100 Gbps)'], - [IFACE_FF_INFINIBAND_XDR, 'XDR (250 Gbps)'], + [IFACE_TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'], + [IFACE_TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'], + [IFACE_TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'], + [IFACE_TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'], + [IFACE_TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'], + [IFACE_TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'], + [IFACE_TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'], + [IFACE_TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'], + [IFACE_TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'], ] ], [ @@ -382,7 +384,8 @@ CONNECTION_STATUS_CHOICES = [ # Cable endpoint types CABLE_TERMINATION_TYPES = [ - 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', + 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', + 'circuittermination', ] # Cable types @@ -510,3 +513,383 @@ POWERFEED_LEG_CHOICES = ( (POWERFEED_LEG_B, 'B'), (POWERFEED_LEG_C, 'C'), ) + + +# +# Interface type values +# + +class InterfaceTypes: + """ + Interface.type slugs + """ + # Virtual + TYPE_VIRTUAL = 'virtual' + TYPE_LAG = 'lag' + + # Ethernet + TYPE_100ME_FIXED = '100base-tx' + TYPE_1GE_FIXED = '1000base-t' + TYPE_1GE_GBIC = '1000base-x-gbic' + TYPE_1GE_SFP = '1000base-x-sfp' + TYPE_2GE_FIXED = '2.5gbase-t' + TYPE_5GE_FIXED = '5gbase-t' + TYPE_10GE_FIXED = '10gbase-t' + TYPE_10GE_CX4 = '10gbase-cx4' + TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp' + TYPE_10GE_XFP = '10gbase-x-xfp' + TYPE_10GE_XENPAK = '10gbase-x-xenpak' + TYPE_10GE_X2 = '10gbase-x-x2' + TYPE_25GE_SFP28 = '25gbase-x-sfp28' + TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp' + TYPE_50GE_QSFP28 = '50gbase-x-sfp28' + TYPE_100GE_CFP = '100gbase-x-cfp' + TYPE_100GE_CFP2 = '100gbase-x-cfp2' + TYPE_100GE_CFP4 = '100gbase-x-cfp4' + TYPE_100GE_CPAK = '100gbase-x-cpak' + TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' + TYPE_200GE_CFP2 = '200gbase-x-cfp2' + TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' + TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' + + # Wireless + TYPE_80211A = 'ieee802.11a' + TYPE_80211G = 'ieee802.11g' + TYPE_80211N = 'ieee802.11n' + TYPE_80211AC = 'ieee802.11ac' + TYPE_80211AD = 'ieee802.11ad' + + # Cellular + TYPE_GSM = 'gsm' + TYPE_CDMA = 'cdma' + TYPE_LTE = 'lte' + + # SONET + TYPE_SONET_OC3 = 'sonet-oc3' + TYPE_SONET_OC12 = 'sonet-oc12' + TYPE_SONET_OC48 = 'sonet-oc48' + TYPE_SONET_OC192 = 'sonet-oc192' + TYPE_SONET_OC768 = 'sonet-oc768' + TYPE_SONET_OC1920 = 'sonet-oc1920' + TYPE_SONET_OC3840 = 'sonet-oc3840' + + # Fibrechannel + TYPE_1GFC_SFP = '1gfc-sfp' + TYPE_2GFC_SFP = '2gfc-sfp' + TYPE_4GFC_SFP = '4gfc-sfp' + TYPE_8GFC_SFP_PLUS = '8gfc-sfpp' + TYPE_16GFC_SFP_PLUS = '16gfc-sfpp' + TYPE_32GFC_SFP28 = '32gfc-sfp28' + TYPE_128GFC_QSFP28 = '128gfc-sfp28' + + # InfiniBand + TYPE_INFINIBAND_SDR = 'inifiband-sdr' + TYPE_INFINIBAND_DDR = 'inifiband-ddr' + TYPE_INFINIBAND_QDR = 'inifiband-qdr' + TYPE_INFINIBAND_FDR10 = 'inifiband-fdr10' + TYPE_INFINIBAND_FDR = 'inifiband-fdr' + TYPE_INFINIBAND_EDR = 'inifiband-edr' + TYPE_INFINIBAND_HDR = 'inifiband-hdr' + TYPE_INFINIBAND_NDR = 'inifiband-ndr' + TYPE_INFINIBAND_XDR = 'inifiband-xdr' + + # Serial + TYPE_T1 = 't1' + TYPE_E1 = 'e1' + TYPE_T3 = 't3' + TYPE_E3 = 'e3' + + # Stacking + TYPE_STACKWISE = 'cisco-stackwise' + TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' + TYPE_FLEXSTACK = 'cisco-flexstack' + TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus' + TYPE_JUNIPER_VCP = 'juniper-vcp' + TYPE_SUMMITSTACK = 'extreme-summitstack' + TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' + TYPE_SUMMITSTACK256 = 'extreme-summitstack-256' + TYPE_SUMMITSTACK512 = 'extreme-summitstack-512' + + # Other + TYPE_OTHER = 'other' + + @classmethod + def as_choices(cls): + return ( + ( + 'Virtual interfaces', + ( + (cls.TYPE_VIRTUAL, 'Virtual'), + (cls.TYPE_LAG, 'Link Aggregation Group (LAG)'), + ), + ), + ( + 'Ethernet (fixed)', + ( + (cls.TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), + (cls.TYPE_1GE_FIXED, '1000BASE-T (1GE)'), + (cls.TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), + (cls.TYPE_5GE_FIXED, '5GBASE-T (5GE)'), + (cls.TYPE_10GE_FIXED, '10GBASE-T (10GE)'), + (cls.TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'), + ) + ), + ( + 'Ethernet (modular)', + ( + (cls.TYPE_1GE_GBIC, 'GBIC (1GE)'), + (cls.TYPE_1GE_SFP, 'SFP (1GE)'), + (cls.TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'), + (cls.TYPE_10GE_XFP, 'XFP (10GE)'), + (cls.TYPE_10GE_XENPAK, 'XENPAK (10GE)'), + (cls.TYPE_10GE_X2, 'X2 (10GE)'), + (cls.TYPE_25GE_SFP28, 'SFP28 (25GE)'), + (cls.TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'), + (cls.TYPE_50GE_QSFP28, 'QSFP28 (50GE)'), + (cls.TYPE_100GE_CFP, 'CFP (100GE)'), + (cls.TYPE_100GE_CFP2, 'CFP2 (100GE)'), + (cls.TYPE_200GE_CFP2, 'CFP2 (200GE)'), + (cls.TYPE_100GE_CFP4, 'CFP4 (100GE)'), + (cls.TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), + (cls.TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), + (cls.TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), + (cls.TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), + ) + ), + ( + 'Wireless', + ( + (cls.TYPE_80211A, 'IEEE 802.11a'), + (cls.TYPE_80211G, 'IEEE 802.11b/g'), + (cls.TYPE_80211N, 'IEEE 802.11n'), + (cls.TYPE_80211AC, 'IEEE 802.11ac'), + (cls.TYPE_80211AD, 'IEEE 802.11ad'), + ) + ), + ( + 'Cellular', + ( + (cls.TYPE_GSM, 'GSM'), + (cls.TYPE_CDMA, 'CDMA'), + (cls.TYPE_LTE, 'LTE'), + ) + ), + ( + 'SONET', + ( + (cls.TYPE_SONET_OC3, 'OC-3/STM-1'), + (cls.TYPE_SONET_OC12, 'OC-12/STM-4'), + (cls.TYPE_SONET_OC48, 'OC-48/STM-16'), + (cls.TYPE_SONET_OC192, 'OC-192/STM-64'), + (cls.TYPE_SONET_OC768, 'OC-768/STM-256'), + (cls.TYPE_SONET_OC1920, 'OC-1920/STM-640'), + (cls.TYPE_SONET_OC3840, 'OC-3840/STM-1234'), + ) + ), + ( + 'FibreChannel', + ( + (cls.TYPE_1GFC_SFP, 'SFP (1GFC)'), + (cls.TYPE_2GFC_SFP, 'SFP (2GFC)'), + (cls.TYPE_4GFC_SFP, 'SFP (4GFC)'), + (cls.TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), + (cls.TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), + (cls.TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), + (cls.TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), + ) + ), + ( + 'InfiniBand', + ( + (cls.TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'), + (cls.TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'), + (cls.TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'), + (cls.TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'), + (cls.TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'), + (cls.TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'), + (cls.TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'), + (cls.TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'), + (cls.TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'), + ) + ), + ( + 'Serial', + ( + (cls.TYPE_T1, 'T1 (1.544 Mbps)'), + (cls.TYPE_E1, 'E1 (2.048 Mbps)'), + (cls.TYPE_T3, 'T3 (45 Mbps)'), + (cls.TYPE_E3, 'E3 (34 Mbps)'), + ) + ), + ( + 'Stacking', + ( + (cls.TYPE_STACKWISE, 'Cisco StackWise'), + (cls.TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), + (cls.TYPE_FLEXSTACK, 'Cisco FlexStack'), + (cls.TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), + (cls.TYPE_JUNIPER_VCP, 'Juniper VCP'), + (cls.TYPE_SUMMITSTACK, 'Extreme SummitStack'), + (cls.TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), + (cls.TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'), + (cls.TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'), + ) + ), + ( + 'Other', + ( + (cls.TYPE_OTHER, 'Other'), + ) + ), + ) + + @classmethod + def slug_to_integer(cls, slug): + """ + Provide backward-compatible mapping of the type slug to integer. + """ + return { + # Slug: integer + cls.TYPE_VIRTUAL: IFACE_TYPE_VIRTUAL, + cls.TYPE_LAG: IFACE_TYPE_LAG, + cls.TYPE_100ME_FIXED: IFACE_TYPE_100ME_FIXED, + cls.TYPE_1GE_FIXED: IFACE_TYPE_1GE_FIXED, + cls.TYPE_1GE_GBIC: IFACE_TYPE_1GE_GBIC, + cls.TYPE_1GE_SFP: IFACE_TYPE_1GE_SFP, + cls.TYPE_2GE_FIXED: IFACE_TYPE_2GE_FIXED, + cls.TYPE_5GE_FIXED: IFACE_TYPE_5GE_FIXED, + cls.TYPE_10GE_FIXED: IFACE_TYPE_10GE_FIXED, + cls.TYPE_10GE_CX4: IFACE_TYPE_10GE_CX4, + cls.TYPE_10GE_SFP_PLUS: IFACE_TYPE_10GE_SFP_PLUS, + cls.TYPE_10GE_XFP: IFACE_TYPE_10GE_XFP, + cls.TYPE_10GE_XENPAK: IFACE_TYPE_10GE_XENPAK, + cls.TYPE_10GE_X2: IFACE_TYPE_10GE_X2, + cls.TYPE_25GE_SFP28: IFACE_TYPE_25GE_SFP28, + cls.TYPE_40GE_QSFP_PLUS: IFACE_TYPE_40GE_QSFP_PLUS, + cls.TYPE_50GE_QSFP28: IFACE_TYPE_50GE_QSFP28, + cls.TYPE_100GE_CFP: IFACE_TYPE_100GE_CFP, + cls.TYPE_100GE_CFP2: IFACE_TYPE_100GE_CFP2, + cls.TYPE_100GE_CFP4: IFACE_TYPE_100GE_CFP4, + cls.TYPE_100GE_CPAK: IFACE_TYPE_100GE_CPAK, + cls.TYPE_100GE_QSFP28: IFACE_TYPE_100GE_QSFP28, + cls.TYPE_200GE_CFP2: IFACE_TYPE_200GE_CFP2, + cls.TYPE_200GE_QSFP56: IFACE_TYPE_200GE_QSFP56, + cls.TYPE_400GE_QSFP_DD: IFACE_TYPE_400GE_QSFP_DD, + cls.TYPE_80211A: IFACE_TYPE_80211A, + cls.TYPE_80211G: IFACE_TYPE_80211G, + cls.TYPE_80211N: IFACE_TYPE_80211N, + cls.TYPE_80211AC: IFACE_TYPE_80211AC, + cls.TYPE_80211AD: IFACE_TYPE_80211AD, + cls.TYPE_GSM: IFACE_TYPE_GSM, + cls.TYPE_CDMA: IFACE_TYPE_CDMA, + cls.TYPE_LTE: IFACE_TYPE_LTE, + cls.TYPE_SONET_OC3: IFACE_TYPE_SONET_OC3, + cls.TYPE_SONET_OC12: IFACE_TYPE_SONET_OC12, + cls.TYPE_SONET_OC48: IFACE_TYPE_SONET_OC48, + cls.TYPE_SONET_OC192: IFACE_TYPE_SONET_OC192, + cls.TYPE_SONET_OC768: IFACE_TYPE_SONET_OC768, + cls.TYPE_SONET_OC1920: IFACE_TYPE_SONET_OC1920, + cls.TYPE_SONET_OC3840: IFACE_TYPE_SONET_OC3840, + cls.TYPE_1GFC_SFP: IFACE_TYPE_1GFC_SFP, + cls.TYPE_2GFC_SFP: IFACE_TYPE_2GFC_SFP, + cls.TYPE_4GFC_SFP: IFACE_TYPE_4GFC_SFP, + cls.TYPE_8GFC_SFP_PLUS: IFACE_TYPE_8GFC_SFP_PLUS, + cls.TYPE_16GFC_SFP_PLUS: IFACE_TYPE_16GFC_SFP_PLUS, + cls.TYPE_32GFC_SFP28: IFACE_TYPE_32GFC_SFP28, + cls.TYPE_128GFC_QSFP28: IFACE_TYPE_128GFC_QSFP28, + cls.TYPE_INFINIBAND_SDR: IFACE_TYPE_INFINIBAND_SDR, + cls.TYPE_INFINIBAND_DDR: IFACE_TYPE_INFINIBAND_DDR, + cls.TYPE_INFINIBAND_QDR: IFACE_TYPE_INFINIBAND_QDR, + cls.TYPE_INFINIBAND_FDR10: IFACE_TYPE_INFINIBAND_FDR10, + cls.TYPE_INFINIBAND_FDR: IFACE_TYPE_INFINIBAND_FDR, + cls.TYPE_INFINIBAND_EDR: IFACE_TYPE_INFINIBAND_EDR, + cls.TYPE_INFINIBAND_HDR: IFACE_TYPE_INFINIBAND_HDR, + cls.TYPE_INFINIBAND_NDR: IFACE_TYPE_INFINIBAND_NDR, + cls.TYPE_INFINIBAND_XDR: IFACE_TYPE_INFINIBAND_XDR, + cls.TYPE_T1: IFACE_TYPE_T1, + cls.TYPE_E1: IFACE_TYPE_E1, + cls.TYPE_T3: IFACE_TYPE_T3, + cls.TYPE_E3: IFACE_TYPE_E3, + cls.TYPE_STACKWISE: IFACE_TYPE_STACKWISE, + cls.TYPE_STACKWISE_PLUS: IFACE_TYPE_STACKWISE_PLUS, + cls.TYPE_FLEXSTACK: IFACE_TYPE_FLEXSTACK, + cls.TYPE_FLEXSTACK_PLUS: IFACE_TYPE_FLEXSTACK_PLUS, + cls.TYPE_JUNIPER_VCP: IFACE_TYPE_JUNIPER_VCP, + cls.TYPE_SUMMITSTACK: IFACE_TYPE_SUMMITSTACK, + cls.TYPE_SUMMITSTACK128: IFACE_TYPE_SUMMITSTACK128, + cls.TYPE_SUMMITSTACK256: IFACE_TYPE_SUMMITSTACK256, + cls.TYPE_SUMMITSTACK512: IFACE_TYPE_SUMMITSTACK512, + }.get(slug) + + +# +# Port type values +# + +class PortTypes: + """ + FrontPort/RearPort.type slugs + """ + TYPE_8P8C = '8p8c' + TYPE_110_PUNCH = '110-punch' + TYPE_BNC = 'bnc' + TYPE_ST = 'st' + TYPE_SC = 'sc' + TYPE_SC_APC = 'sc-apc' + TYPE_FC = 'fc' + TYPE_LC = 'lc' + TYPE_LC_APC = 'lc-apc' + TYPE_MTRJ = 'mtrj' + TYPE_MPO = 'mpo' + TYPE_LSH = 'lsh' + TYPE_LSH_APC = 'lsh-apc' + + @classmethod + def as_choices(cls): + return ( + ( + 'Copper', + ( + (cls.TYPE_8P8C, '8P8C'), + (cls.TYPE_110_PUNCH, '110 Punch'), + (cls.TYPE_BNC, 'BNC'), + ), + ), + ( + 'Fiber Optic', + ( + (cls.TYPE_FC, 'FC'), + (cls.TYPE_LC, 'LC'), + (cls.TYPE_LC_APC, 'LC/APC'), + (cls.TYPE_LSH, 'LSH'), + (cls.TYPE_LSH_APC, 'LSH/APC'), + (cls.TYPE_MPO, 'MPO'), + (cls.TYPE_MTRJ, 'MTRJ'), + (cls.TYPE_SC, 'SC'), + (cls.TYPE_SC_APC, 'SC/APC'), + (cls.TYPE_ST, 'ST'), + ) + ) + ) + + @classmethod + def slug_to_integer(cls, slug): + """ + Provide backward-compatible mapping of the type slug to integer. + """ + return { + # Slug: integer + cls.TYPE_8P8C: PORT_TYPE_8P8C, + cls.TYPE_110_PUNCH: PORT_TYPE_8P8C, + cls.TYPE_BNC: PORT_TYPE_BNC, + cls.TYPE_ST: PORT_TYPE_ST, + cls.TYPE_SC: PORT_TYPE_SC, + cls.TYPE_SC_APC: PORT_TYPE_SC_APC, + cls.TYPE_FC: PORT_TYPE_FC, + cls.TYPE_LC: PORT_TYPE_LC, + cls.TYPE_LC_APC: PORT_TYPE_LC_APC, + cls.TYPE_MTRJ: PORT_TYPE_MTRJ, + cls.TYPE_MPO: PORT_TYPE_MPO, + cls.TYPE_LSH: PORT_TYPE_LSH, + cls.TYPE_LSH_APC: PORT_TYPE_LSH_APC, + }.get(slug) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4597a05e3..279b00dd9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1289,6 +1289,9 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class InterfaceTemplateImportForm(ComponentTemplateImportForm): + type = forms.ChoiceField( + choices=InterfaceTypes.as_choices() + ) class Meta: model = InterfaceTemplate @@ -1296,8 +1299,16 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): 'device_type', 'name', 'type', 'mgmt_only', ] + def clean_type(self): + # Convert slug value to field integer value + slug = self.cleaned_data['type'] + return InterfaceTypes.slug_to_integer(slug) + class FrontPortTemplateImportForm(ComponentTemplateImportForm): + type = forms.ChoiceField( + choices=PortTypes.as_choices() + ) rear_port = forms.ModelChoiceField( queryset=RearPortTemplate.objects.all(), to_field_name='name', @@ -1310,8 +1321,16 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', ] + def clean_type(self): + # Convert slug value to field integer value + slug = self.cleaned_data['type'] + return PortTypes.slug_to_integer(slug) + class RearPortTemplateImportForm(ComponentTemplateImportForm): + type = forms.ChoiceField( + choices=PortTypes.as_choices() + ) class Meta: model = RearPortTemplate @@ -1319,6 +1338,11 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): 'device_type', 'name', 'type', 'positions', ] + def clean_type(self): + # Convert slug value to field integer value + slug = self.cleaned_data['type'] + return PortTypes.slug_to_integer(slug) + class DeviceBayTemplateImportForm(ComponentTemplateImportForm): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 754a2dd83..d4572cc39 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -253,28 +253,28 @@ power-outlets: feed_leg: 1 interfaces: - name: Interface 1 - type: 1000 + type: 1000base-t mgmt_only: true - name: Interface 2 - type: 1000 + type: 1000base-t - name: Interface 3 - type: 1000 + type: 1000base-t rear-ports: - name: Rear Port 1 - type: 1000 + type: 8p8c - name: Rear Port 2 - type: 1000 + type: 8p8c - name: Rear Port 3 - type: 1000 + type: 8p8c front-ports: - name: Front Port 1 - type: 1000 + type: 8p8c rear_port: Rear Port 1 - name: Front Port 2 - type: 1000 + type: 8p8c rear_port: Rear Port 2 - name: Front Port 3 - type: 1000 + type: 8p8c rear_port: Rear Port 3 device-bays: - name: Device Bay 1 From 2ffbced47ed56351c69a7d68a9dea7a7b6325291 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Oct 2019 16:38:31 -0400 Subject: [PATCH 17/17] Rework InterfaceTypes and PortTypes classes --- netbox/dcim/constants.py | 274 +++++++++++++++++++-------------------- netbox/dcim/forms.py | 6 +- 2 files changed, 138 insertions(+), 142 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 9d20229a1..02662d9f8 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -613,135 +613,133 @@ class InterfaceTypes: # Other TYPE_OTHER = 'other' - @classmethod - def as_choices(cls): - return ( + TYPE_CHOICES = ( + ( + 'Virtual interfaces', ( - 'Virtual interfaces', - ( - (cls.TYPE_VIRTUAL, 'Virtual'), - (cls.TYPE_LAG, 'Link Aggregation Group (LAG)'), - ), + (TYPE_VIRTUAL, 'Virtual'), + (TYPE_LAG, 'Link Aggregation Group (LAG)'), ), + ), + ( + 'Ethernet (fixed)', ( - 'Ethernet (fixed)', - ( - (cls.TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), - (cls.TYPE_1GE_FIXED, '1000BASE-T (1GE)'), - (cls.TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), - (cls.TYPE_5GE_FIXED, '5GBASE-T (5GE)'), - (cls.TYPE_10GE_FIXED, '10GBASE-T (10GE)'), - (cls.TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'), - ) - ), + (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), + (TYPE_1GE_FIXED, '1000BASE-T (1GE)'), + (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), + (TYPE_5GE_FIXED, '5GBASE-T (5GE)'), + (TYPE_10GE_FIXED, '10GBASE-T (10GE)'), + (TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'), + ) + ), + ( + 'Ethernet (modular)', ( - 'Ethernet (modular)', - ( - (cls.TYPE_1GE_GBIC, 'GBIC (1GE)'), - (cls.TYPE_1GE_SFP, 'SFP (1GE)'), - (cls.TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'), - (cls.TYPE_10GE_XFP, 'XFP (10GE)'), - (cls.TYPE_10GE_XENPAK, 'XENPAK (10GE)'), - (cls.TYPE_10GE_X2, 'X2 (10GE)'), - (cls.TYPE_25GE_SFP28, 'SFP28 (25GE)'), - (cls.TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'), - (cls.TYPE_50GE_QSFP28, 'QSFP28 (50GE)'), - (cls.TYPE_100GE_CFP, 'CFP (100GE)'), - (cls.TYPE_100GE_CFP2, 'CFP2 (100GE)'), - (cls.TYPE_200GE_CFP2, 'CFP2 (200GE)'), - (cls.TYPE_100GE_CFP4, 'CFP4 (100GE)'), - (cls.TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), - (cls.TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), - (cls.TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), - (cls.TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), - ) - ), + (TYPE_1GE_GBIC, 'GBIC (1GE)'), + (TYPE_1GE_SFP, 'SFP (1GE)'), + (TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'), + (TYPE_10GE_XFP, 'XFP (10GE)'), + (TYPE_10GE_XENPAK, 'XENPAK (10GE)'), + (TYPE_10GE_X2, 'X2 (10GE)'), + (TYPE_25GE_SFP28, 'SFP28 (25GE)'), + (TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'), + (TYPE_50GE_QSFP28, 'QSFP28 (50GE)'), + (TYPE_100GE_CFP, 'CFP (100GE)'), + (TYPE_100GE_CFP2, 'CFP2 (100GE)'), + (TYPE_200GE_CFP2, 'CFP2 (200GE)'), + (TYPE_100GE_CFP4, 'CFP4 (100GE)'), + (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), + (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), + (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), + (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), + ) + ), + ( + 'Wireless', ( - 'Wireless', - ( - (cls.TYPE_80211A, 'IEEE 802.11a'), - (cls.TYPE_80211G, 'IEEE 802.11b/g'), - (cls.TYPE_80211N, 'IEEE 802.11n'), - (cls.TYPE_80211AC, 'IEEE 802.11ac'), - (cls.TYPE_80211AD, 'IEEE 802.11ad'), - ) - ), + (TYPE_80211A, 'IEEE 802.11a'), + (TYPE_80211G, 'IEEE 802.11b/g'), + (TYPE_80211N, 'IEEE 802.11n'), + (TYPE_80211AC, 'IEEE 802.11ac'), + (TYPE_80211AD, 'IEEE 802.11ad'), + ) + ), + ( + 'Cellular', ( - 'Cellular', - ( - (cls.TYPE_GSM, 'GSM'), - (cls.TYPE_CDMA, 'CDMA'), - (cls.TYPE_LTE, 'LTE'), - ) - ), + (TYPE_GSM, 'GSM'), + (TYPE_CDMA, 'CDMA'), + (TYPE_LTE, 'LTE'), + ) + ), + ( + 'SONET', ( - 'SONET', - ( - (cls.TYPE_SONET_OC3, 'OC-3/STM-1'), - (cls.TYPE_SONET_OC12, 'OC-12/STM-4'), - (cls.TYPE_SONET_OC48, 'OC-48/STM-16'), - (cls.TYPE_SONET_OC192, 'OC-192/STM-64'), - (cls.TYPE_SONET_OC768, 'OC-768/STM-256'), - (cls.TYPE_SONET_OC1920, 'OC-1920/STM-640'), - (cls.TYPE_SONET_OC3840, 'OC-3840/STM-1234'), - ) - ), + (TYPE_SONET_OC3, 'OC-3/STM-1'), + (TYPE_SONET_OC12, 'OC-12/STM-4'), + (TYPE_SONET_OC48, 'OC-48/STM-16'), + (TYPE_SONET_OC192, 'OC-192/STM-64'), + (TYPE_SONET_OC768, 'OC-768/STM-256'), + (TYPE_SONET_OC1920, 'OC-1920/STM-640'), + (TYPE_SONET_OC3840, 'OC-3840/STM-1234'), + ) + ), + ( + 'FibreChannel', ( - 'FibreChannel', - ( - (cls.TYPE_1GFC_SFP, 'SFP (1GFC)'), - (cls.TYPE_2GFC_SFP, 'SFP (2GFC)'), - (cls.TYPE_4GFC_SFP, 'SFP (4GFC)'), - (cls.TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), - (cls.TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), - (cls.TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), - (cls.TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), - ) - ), + (TYPE_1GFC_SFP, 'SFP (1GFC)'), + (TYPE_2GFC_SFP, 'SFP (2GFC)'), + (TYPE_4GFC_SFP, 'SFP (4GFC)'), + (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), + (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), + (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), + (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), + ) + ), + ( + 'InfiniBand', ( - 'InfiniBand', - ( - (cls.TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'), - (cls.TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'), - (cls.TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'), - (cls.TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'), - (cls.TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'), - (cls.TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'), - (cls.TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'), - (cls.TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'), - (cls.TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'), - ) - ), + (TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'), + (TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'), + (TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'), + (TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'), + (TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'), + (TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'), + (TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'), + (TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'), + (TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'), + ) + ), + ( + 'Serial', ( - 'Serial', - ( - (cls.TYPE_T1, 'T1 (1.544 Mbps)'), - (cls.TYPE_E1, 'E1 (2.048 Mbps)'), - (cls.TYPE_T3, 'T3 (45 Mbps)'), - (cls.TYPE_E3, 'E3 (34 Mbps)'), - ) - ), + (TYPE_T1, 'T1 (1.544 Mbps)'), + (TYPE_E1, 'E1 (2.048 Mbps)'), + (TYPE_T3, 'T3 (45 Mbps)'), + (TYPE_E3, 'E3 (34 Mbps)'), + ) + ), + ( + 'Stacking', ( - 'Stacking', - ( - (cls.TYPE_STACKWISE, 'Cisco StackWise'), - (cls.TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), - (cls.TYPE_FLEXSTACK, 'Cisco FlexStack'), - (cls.TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), - (cls.TYPE_JUNIPER_VCP, 'Juniper VCP'), - (cls.TYPE_SUMMITSTACK, 'Extreme SummitStack'), - (cls.TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), - (cls.TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'), - (cls.TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'), - ) - ), + (TYPE_STACKWISE, 'Cisco StackWise'), + (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), + (TYPE_FLEXSTACK, 'Cisco FlexStack'), + (TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), + (TYPE_JUNIPER_VCP, 'Juniper VCP'), + (TYPE_SUMMITSTACK, 'Extreme SummitStack'), + (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), + (TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'), + (TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'), + ) + ), + ( + 'Other', ( - 'Other', - ( - (cls.TYPE_OTHER, 'Other'), - ) - ), - ) + (TYPE_OTHER, 'Other'), + ) + ), + ) @classmethod def slug_to_integer(cls, slug): @@ -844,33 +842,31 @@ class PortTypes: TYPE_LSH = 'lsh' TYPE_LSH_APC = 'lsh-apc' - @classmethod - def as_choices(cls): - return ( + TYPE_CHOICES = ( + ( + 'Copper', ( - 'Copper', - ( - (cls.TYPE_8P8C, '8P8C'), - (cls.TYPE_110_PUNCH, '110 Punch'), - (cls.TYPE_BNC, 'BNC'), - ), + (TYPE_8P8C, '8P8C'), + (TYPE_110_PUNCH, '110 Punch'), + (TYPE_BNC, 'BNC'), ), + ), + ( + 'Fiber Optic', ( - 'Fiber Optic', - ( - (cls.TYPE_FC, 'FC'), - (cls.TYPE_LC, 'LC'), - (cls.TYPE_LC_APC, 'LC/APC'), - (cls.TYPE_LSH, 'LSH'), - (cls.TYPE_LSH_APC, 'LSH/APC'), - (cls.TYPE_MPO, 'MPO'), - (cls.TYPE_MTRJ, 'MTRJ'), - (cls.TYPE_SC, 'SC'), - (cls.TYPE_SC_APC, 'SC/APC'), - (cls.TYPE_ST, 'ST'), - ) + (TYPE_FC, 'FC'), + (TYPE_LC, 'LC'), + (TYPE_LC_APC, 'LC/APC'), + (TYPE_LSH, 'LSH'), + (TYPE_LSH_APC, 'LSH/APC'), + (TYPE_MPO, 'MPO'), + (TYPE_MTRJ, 'MTRJ'), + (TYPE_SC, 'SC'), + (TYPE_SC_APC, 'SC/APC'), + (TYPE_ST, 'ST'), ) ) + ) @classmethod def slug_to_integer(cls, slug): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 279b00dd9..6513cfee2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1290,7 +1290,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( - choices=InterfaceTypes.as_choices() + choices=InterfaceTypes.TYPE_CHOICES ) class Meta: @@ -1307,7 +1307,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class FrontPortTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( - choices=PortTypes.as_choices() + choices=PortTypes.TYPE_CHOICES ) rear_port = forms.ModelChoiceField( queryset=RearPortTemplate.objects.all(), @@ -1329,7 +1329,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): class RearPortTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( - choices=PortTypes.as_choices() + choices=PortTypes.TYPE_CHOICES ) class Meta: