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