Initial work on #655: CSV import headers

This commit is contained in:
Jeremy Stretch 2017-05-31 17:40:11 -04:00
parent 293dbd8a8b
commit a598f0e632
6 changed files with 168 additions and 52 deletions

View File

@ -1,40 +1,3 @@
{% extends 'utilities/obj_import.html' %}
{% 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 %}

View File

@ -28,6 +28,23 @@
</div>
<div class="col-md-6">
{% 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>
{% endblock %}

View File

@ -5,8 +5,7 @@ from django.db.models import Count
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
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
@ -36,17 +35,19 @@ class TenantForm(BootstrapMixin, CustomFieldForm):
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:
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):

View File

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

View File

@ -256,6 +256,60 @@ class CSVDataField(forms.CharField):
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):
"""
A field which allows for numeric range expansion
@ -488,7 +542,7 @@ class BulkEditForm(forms.Form):
class BulkImportForm(forms.Form):
def clean(self):
records = self.cleaned_data.get('csv')
fields, records = self.cleaned_data.get('csv').split('\n', 1)
if not records:
return

View File

@ -6,9 +6,10 @@ from django_tables2 import RequestConfig
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
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.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError
@ -19,6 +20,7 @@ from django.utils.safestring import mark_safe
from django.views.generic import View
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
from utilities.forms import BootstrapMixin, CSVDataField2
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm
from .paginator import EnhancedPaginator
@ -422,6 +424,85 @@ class BulkImportView(View):
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):
"""
Edit objects in bulk.