فهرست منبع

Initial work on JSON/YAML-based DeviceType import

Jeremy Stretch 6 سال پیش
والد
کامیت
f8fdca4968

+ 32 - 1
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(),

+ 2 - 1
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/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),

+ 9 - 1
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

+ 1 - 1
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' %}

+ 1 - 1
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' %}

+ 1 - 1
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 %}

+ 60 - 0
netbox/templates/utilities/obj_bulk_import.html

@@ -0,0 +1,60 @@
+{% extends '_base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block content %}
+<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
+{% block tabs %}{% endblock %}
+<div class="row">
+	<div class="col-md-7">
+        {% if form.non_field_errors %}
+            <div class="panel panel-danger">
+                <div class="panel-heading"><strong>Errors</strong></div>
+                <div class="panel-body">
+                    {{ form.non_field_errors }}
+                </div>
+            </div>
+        {% endif %}
+		<form action="" method="post" class="form">
+		    {% csrf_token %}
+		    {% render_form form %}
+            <div class="form-group">
+                <div class="col-md-12 text-right">
+		            <button type="submit" class="btn btn-primary">Submit</button>
+		            {% if return_url %}
+                        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                    {% endif %}
+                </div>
+            </div>
+		</form>
+	</div>
+	<div class="col-md-5">
+        {% if fields %}
+            <h4 class="text-center">CSV Format</h4>
+            <table class="table">
+                <tr>
+                    <th>Field</th>
+                    <th>Required</th>
+                    <th>Description</th>
+                </tr>
+                {% for name, field in fields.items %}
+                    <tr>
+                        <td><code>{{ name }}</code></td>
+                        <td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
+                        <td>
+                            {{ field.help_text|default:field.label }}
+                            {% if field.choices %}
+                                <br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
+                            {% elif field|widget_type == 'dateinput' %}
+                                <br /><small class="text-muted">Format: YYYY-MM-DD</small>
+                            {% elif field|widget_type == 'checkboxinput' %}
+                                <br /><small class="text-muted">Specify "true" or "false"</small>
+                            {% endif %}
+                        </td>
+                    </tr>
+                {% endfor %}
+            </table>
+        {% endif %}
+	</div>
+</div>
+{% endblock %}

+ 1 - 29
netbox/templates/utilities/obj_import.html

@@ -6,7 +6,7 @@
 <h1>{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}</h1>
 {% block tabs %}{% endblock %}
 <div class="row">
-	<div class="col-md-7">
+	<div class="col-md-8 col-md-offset-2">
         {% if form.non_field_errors %}
             <div class="panel panel-danger">
                 <div class="panel-heading"><strong>Errors</strong></div>
@@ -28,33 +28,5 @@
             </div>
 		</form>
 	</div>
-	<div class="col-md-5">
-        {% if fields %}
-            <h4 class="text-center">CSV Format</h4>
-            <table class="table">
-                <tr>
-                    <th>Field</th>
-                    <th>Required</th>
-                    <th>Description</th>
-                </tr>
-                {% for name, field in fields.items %}
-                    <tr>
-                        <td><code>{{ name }}</code></td>
-                        <td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
-                        <td>
-                            {{ field.help_text|default:field.label }}
-                            {% if field.choices %}
-                                <br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
-                            {% elif field|widget_type == 'dateinput' %}
-                                <br /><small class="text-muted">Format: YYYY-MM-DD</small>
-                            {% elif field|widget_type == 'checkboxinput' %}
-                                <br /><small class="text-muted">Specify "true" or "false"</small>
-                            {% endif %}
-                        </td>
-                    </tr>
-                {% endfor %}
-            </table>
-        {% endif %}
-	</div>
 </div>
 {% endblock %}

+ 35 - 2
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'
+    )

+ 60 - 2
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):