From f8fdca49680fe0de8b635628a2149d2920fb1f28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Sep 2019 17:23:56 -0400 Subject: [PATCH] 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):