Browse Source

Initial work on #655: CSV import headers

Jeremy Stretch 8 years ago
parent
commit
a598f0e632

+ 0 - 37
netbox/templates/tenancy/tenant_import.html

@@ -1,40 +1,3 @@
 {% extends 'utilities/obj_import.html' %}
 {% extends 'utilities/obj_import.html' %}
 
 
 {% block title %}Tenant Import{% endblock %}
 {% block title %}Tenant Import{% endblock %}
-
-{% block instructions %}
-    <h4>CSV Format</h4>
-    <table class="table">
-        <thead>
-            <tr>
-                <th>Field</th>
-                <th>Description</th>
-                <th>Example</th>
-            </tr>
-        </thead>
-        <tbody>
-            <tr>
-                <td>Name</td>
-                <td>Tenant name</td>
-                <td>WIDG01</td>
-            </tr>
-            <tr>
-                <td>Slug</td>
-                <td>URL-friendly name</td>
-                <td>widg01</td>
-            </tr>
-            <tr>
-                <td>Group</td>
-                <td>Tenant group (optional)</td>
-                <td>Customers</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Long-form name or other text (optional)</td>
-                <td>Widgets Inc.</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>WIDG01,widg01,Customers,Widgets Inc.</pre>
-{% endblock %}

+ 17 - 0
netbox/templates/utilities/obj_import.html

@@ -28,6 +28,23 @@
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         {% block instructions %}{% endblock %}
         {% block instructions %}{% endblock %}
+        {% if fields %}
+            <h4>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 }}</td>
+                    </tr>
+                {% endfor %}
+            </table>
+        {% endif %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 11 - 10
netbox/tenancy/forms.py

@@ -5,8 +5,7 @@ from django.db.models import Count
 
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
-    FilterChoiceField, SlugField,
+    APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField,
 )
 )
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
@@ -36,17 +35,19 @@ class TenantForm(BootstrapMixin, CustomFieldForm):
         fields = ['name', 'slug', 'group', 'description', 'comments']
         fields = ['name', 'slug', 'group', 'description', 'comments']
 
 
 
 
-class TenantFromCSVForm(forms.ModelForm):
-    group = forms.ModelChoiceField(TenantGroup.objects.all(), required=False, to_field_name='name',
-                                   error_messages={'invalid_choice': 'Group not found.'})
+class TenantCSVForm(forms.ModelForm):
+    group = forms.ModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        error_messages={
+            'invalid_choice': 'Group not found.'
+        }
+    )
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = ['name', 'slug', 'group', 'description']
-
-
-class TenantImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=TenantFromCSVForm)
+        fields = ['name', 'slug', 'group', 'description', 'comments']
 
 
 
 
 class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 3 - 3
netbox/tenancy/views.py

@@ -10,7 +10,7 @@ from circuits.models import Circuit
 from dcim.models import Site, Rack, Device
 from dcim.models import Site, Rack, Device
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from utilities.views import (
 from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkDeleteView, BulkEditView, BulkImportView, BulkImportView2, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 from . import filters, forms, tables
 from . import filters, forms, tables
@@ -95,9 +95,9 @@ class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'
 
 
 
 
-class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
+class TenantBulkImportView(PermissionRequiredMixin, BulkImportView2):
     permission_required = 'tenancy.add_tenant'
     permission_required = 'tenancy.add_tenant'
-    form = forms.TenantImportForm
+    model_form = forms.TenantCSVForm
     table = tables.TenantTable
     table = tables.TenantTable
     template_name = 'tenancy/tenant_import.html'
     template_name = 'tenancy/tenant_import.html'
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'

+ 55 - 1
netbox/utilities/forms.py

@@ -256,6 +256,60 @@ class CSVDataField(forms.CharField):
         return records
         return records
 
 
 
 
+class CSVDataField2(forms.CharField):
+    """
+    A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
+    column headers to values. Each dictionary represents an individual record.
+    """
+    widget = forms.Textarea
+
+    def __init__(self, fields, required_fields=[], *args, **kwargs):
+
+        self.fields = fields
+        self.required_fields = required_fields
+
+        super(CSVDataField2, self).__init__(*args, **kwargs)
+
+        self.strip = False
+        if not self.label:
+            self.label = 'CSV Data'
+        if not self.initial:
+            self.initial = ','.join(required_fields) + '\n'
+        if not self.help_text:
+            self.help_text = 'Enter one line per record. Use commas to separate values.'
+
+    def to_python(self, value):
+
+        # Python 2's csv module has problems with Unicode
+        if not isinstance(value, str):
+            value = value.encode('utf-8')
+
+        records = []
+        reader = csv.reader(value.splitlines())
+
+        # Consume and valdiate the first line of CSV data as column headers
+        headers = reader.next()
+        for f in self.required_fields:
+            if f not in headers:
+                raise forms.ValidationError('Required column header "{}" not found.'.format(f))
+        for f in headers:
+            if f not in self.fields:
+                raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
+
+        # Parse CSV data
+        for i, row in enumerate(reader, start=1):
+            if row:
+                if len(row) != len(headers):
+                    raise forms.ValidationError(
+                        "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
+                    )
+                row = [col.strip() for col in row]
+                record = dict(zip(headers, row))
+                records.append(record)
+
+        return records
+
+
 class ExpandableNameField(forms.CharField):
 class ExpandableNameField(forms.CharField):
     """
     """
     A field which allows for numeric range expansion
     A field which allows for numeric range expansion
@@ -488,7 +542,7 @@ class BulkEditForm(forms.Form):
 class BulkImportForm(forms.Form):
 class BulkImportForm(forms.Form):
 
 
     def clean(self):
     def clean(self):
-        records = self.cleaned_data.get('csv')
+        fields, records = self.cleaned_data.get('csv').split('\n', 1)
         if not records:
         if not records:
             return
             return
 
 

+ 82 - 1
netbox/utilities/views.py

@@ -6,9 +6,10 @@ from django_tables2 import RequestConfig
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
 from django.db.models import ProtectedError
 from django.db.models import ProtectedError
-from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
+from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import TemplateSyntaxError
 from django.template import TemplateSyntaxError
@@ -19,6 +20,7 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 
 
 from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
 from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
+from utilities.forms import BootstrapMixin, CSVDataField2
 from .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm
 from .forms import ConfirmationForm
 from .paginator import EnhancedPaginator
 from .paginator import EnhancedPaginator
@@ -422,6 +424,85 @@ class BulkImportView(View):
         obj.save()
         obj.save()
 
 
 
 
+class BulkImportView2(View):
+    """
+    Import objects in bulk (CSV format).
+
+    model_form: The form used to create each imported object
+    table: The django-tables2 Table used to render the list of imported objects
+    template_name: The name of the template
+    default_return_url: The name of the URL to use for the cancel button
+    """
+    model_form = None
+    table = None
+    template_name = None
+    default_return_url = None
+
+    def _import_form(self, *args, **kwargs):
+
+        fields = self.model_form().fields.keys()
+        required_fields = [name for name, field in self.model_form().fields.items() if field.required]
+
+        class ImportForm(BootstrapMixin, Form):
+            csv = CSVDataField2(fields=fields, required_fields=required_fields)
+
+        return ImportForm(*args, **kwargs)
+
+    def get(self, request):
+
+        return render(request, self.template_name, {
+            'form': self._import_form(),
+            'fields': self.model_form().fields,
+            'return_url': self.default_return_url,
+        })
+
+    def post(self, request):
+
+        new_objs = []
+        form = self._import_form(request.POST)
+
+        if form.is_valid():
+
+            try:
+
+                # Iterate through CSV data and bind each row to a new model form instance.
+                with transaction.atomic():
+                    for row, data in enumerate(form.cleaned_data['csv'], start=1):
+                        obj_form = self.model_form(data)
+                        if obj_form.is_valid():
+                            obj = obj_form.save()
+                            new_objs.append(obj)
+                        else:
+                            for field, err in obj_form.errors.items():
+                                form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
+                            raise ValidationError("")
+
+                # Compile a table containing the imported objects
+                obj_table = self.table(new_objs)
+
+                if new_objs:
+                    msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
+                    messages.success(request, msg)
+                    UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
+
+                    return render(request, "import_success.html", {
+                        'table': obj_table,
+                        'return_url': self.default_return_url,
+                    })
+
+            except ValidationError:
+                pass
+
+        return render(request, self.template_name, {
+            'form': form,
+            'fields': self.model_form().fields,
+            'return_url': self.default_return_url,
+        })
+
+    def save_obj(self, obj):
+        obj.save()
+
+
 class BulkEditView(View):
 class BulkEditView(View):
     """
     """
     Edit objects in bulk.
     Edit objects in bulk.