From a598f0e632255d1e2777c365c88e2b9707177503 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 May 2017 17:40:11 -0400 Subject: [PATCH 01/18] Initial work on #655: CSV import headers --- netbox/templates/tenancy/tenant_import.html | 37 --------- netbox/templates/utilities/obj_import.html | 17 +++++ netbox/tenancy/forms.py | 21 +++--- netbox/tenancy/views.py | 6 +- netbox/utilities/forms.py | 56 +++++++++++++- netbox/utilities/views.py | 83 ++++++++++++++++++++- 6 files changed, 168 insertions(+), 52 deletions(-) diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html index 2e5196a88..4d0e97f48 100644 --- a/netbox/templates/tenancy/tenant_import.html +++ b/netbox/templates/tenancy/tenant_import.html @@ -1,40 +1,3 @@ {% extends 'utilities/obj_import.html' %} {% block title %}Tenant Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameTenant nameWIDG01
SlugURL-friendly namewidg01
GroupTenant group (optional)Customers
DescriptionLong-form name or other text (optional)Widgets Inc.
-

Example

-
WIDG01,widg01,Customers,Widgets Inc.
-{% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 2d69be048..325cfa304 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -28,6 +28,23 @@
{% block instructions %}{% endblock %} + {% if fields %} +

CSV Format

+ + + + + + + {% for name, field in fields.items %} + + + + + + {% endfor %} +
FieldRequiredDescription
{{ name }}{% if field.required %}{% endif %}{{ field.help_text }}
+ {% endif %}
{% endblock %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index df4f05b95..32e8263c2 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -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): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 3b5ad9b37..27afed269 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -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' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 37101a56e..fcdaf2c53 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -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 diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 9c7a4b55e..7791e69b7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -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. From 4a8147f8a51759a8575957e229502cb71e8d5328 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Jun 2017 13:40:52 -0400 Subject: [PATCH 02/18] Converted circuits import views to new scheme --- netbox/circuits/forms.py | 57 ++++++++++++------- netbox/circuits/views.py | 12 ++-- netbox/templates/circuits/circuit_import.html | 55 ------------------ .../templates/circuits/provider_import.html | 45 --------------- netbox/templates/tenancy/tenant_import.html | 3 - netbox/templates/utilities/obj_import.html | 5 +- netbox/tenancy/forms.py | 6 ++ netbox/tenancy/views.py | 1 - netbox/utilities/forms.py | 4 +- netbox/utilities/views.py | 4 +- 10 files changed, 57 insertions(+), 135 deletions(-) delete mode 100644 netbox/templates/circuits/circuit_import.html delete mode 100644 netbox/templates/circuits/provider_import.html delete mode 100644 netbox/templates/tenancy/tenant_import.html diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 79cad0a6b..444974240 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, - FilterChoiceField, Livesearch, SmallTextarea, SlugField, + APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, + SmallTextarea, SlugField, ) from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -39,15 +39,17 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderFromCSVForm(forms.ModelForm): +class ProviderCSVForm(forms.ModelForm): + slug = SlugField() class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url'] - - -class ProviderImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ProviderFromCSVForm) + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments'] + help_texts = { + 'name': 'Provider name', + 'asn': 'Autonomous system number', + 'comments': 'Free-form comments' + } class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -102,21 +104,36 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class CircuitFromCSVForm(forms.ModelForm): - provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Provider not found.'}) - type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid circuit type.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) +class CircuitCSVForm(forms.ModelForm): + provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Name of parent provider', + error_messages={ + 'invalid_choice': 'Provider not found.' + } + ) + type = forms.ModelChoiceField( + queryset=CircuitType.objects.all(), + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Invalid circuit type.' + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of circuit type', + error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) class Meta: model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] - - -class CircuitImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=CircuitFromCSVForm) + fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index eed612a33..144007211 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -12,7 +12,7 @@ from django.views.generic import View from extras.models import Graph, GRAPH_TYPE_PROVIDER from utilities.forms import ConfirmationForm from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView2, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z @@ -63,11 +63,10 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'circuits:provider_list' -class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): +class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'circuits.add_provider' - form = forms.ProviderImportForm + model_form = forms.ProviderCSVForm table = tables.ProviderTable - template_name = 'circuits/provider_import.html' default_return_url = 'circuits:provider_list' @@ -161,11 +160,10 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'circuits:circuit_list' -class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): +class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'circuits.add_circuit' - form = forms.CircuitImportForm + model_form = forms.CircuitCSVForm table = tables.CircuitTable - template_name = 'circuits/circuit_import.html' default_return_url = 'circuits:circuit_list' diff --git a/netbox/templates/circuits/circuit_import.html b/netbox/templates/circuits/circuit_import.html deleted file mode 100644 index 4b0c40b09..000000000 --- a/netbox/templates/circuits/circuit_import.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Circuit Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
Circuit IDAlphanumeric circuit identifierIC-603122
ProviderName of circuit providerTeliaSonera
TypeCircuit typeTransit
TenantName of tenant (optional)Strickland Propane
Install DateDate in YYYY-MM-DD format (optional)2016-02-23
Commit rateCommited rate in Kbps (optional)2000
DescriptionShort description (optional)Primary for voice
-

Example

-
IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice
-{% endblock %} diff --git a/netbox/templates/circuits/provider_import.html b/netbox/templates/circuits/provider_import.html deleted file mode 100644 index 2ab2e5efb..000000000 --- a/netbox/templates/circuits/provider_import.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Provider Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameProvider's proper nameLevel 3
SlugURL-friendly namelevel3
ASNAutonomous system number (optional)3356
AccountAccount number (optional)08931544
Portal URLCustomer service portal URL (optional)https://mylevel3.net
-

Example

-
Level 3,level3,3356,08931544,https://mylevel3.net
-{% endblock %} diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html deleted file mode 100644 index 4d0e97f48..000000000 --- a/netbox/templates/tenancy/tenant_import.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Tenant Import{% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 325cfa304..25b737175 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -1,8 +1,9 @@ {% extends '_base.html' %} +{% load helpers %} {% load form_helpers %} {% block content %} -

{% block title %}{% endblock %}

+

{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}

{% if form.non_field_errors %} @@ -40,7 +41,7 @@ {{ name }} {% if field.required %}{% endif %} - {{ field.help_text }} + {{ field.help_text|default:field.label }} {% endfor %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 32e8263c2..9950abfc2 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -36,10 +36,12 @@ class TenantForm(BootstrapMixin, CustomFieldForm): class TenantCSVForm(forms.ModelForm): + slug = SlugField() group = forms.ModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', + help_text='Name of parent group', error_messages={ 'invalid_choice': 'Group not found.' } @@ -48,6 +50,10 @@ class TenantCSVForm(forms.ModelForm): class Meta: model = Tenant fields = ['name', 'slug', 'group', 'description', 'comments'] + help_texts = { + 'name': 'Tenant name', + 'comments': 'Free-form comments' + } class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 27afed269..feaec38be 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -99,7 +99,6 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'tenancy.add_tenant' model_form = forms.TenantCSVForm table = tables.TenantTable - template_name = 'tenancy/tenant_import.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index fcdaf2c53..6c07aad7f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -276,7 +276,9 @@ class CSVDataField2(forms.CharField): 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.' + self.help_text = 'Enter the list of column headers followed by one line per record to be imported. Use ' \ + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ + 'in double quotes.' def to_python(self, value): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7791e69b7..e06b900c0 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -435,8 +435,8 @@ class BulkImportView2(View): """ model_form = None table = None - template_name = None default_return_url = None + template_name = 'utilities/obj_import.html' def _import_form(self, *args, **kwargs): @@ -453,6 +453,7 @@ class BulkImportView2(View): return render(request, self.template_name, { 'form': self._import_form(), 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.default_return_url, }) @@ -496,6 +497,7 @@ class BulkImportView2(View): return render(request, self.template_name, { 'form': form, 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.default_return_url, }) From 7e660d4d8eea15e8d3291b4aba309285dee8361f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Jun 2017 14:49:25 -0400 Subject: [PATCH 03/18] Converted site/rack/device import views to new scheme --- netbox/dcim/forms.py | 188 ++++++++++++------ netbox/dcim/views.py | 24 +-- netbox/templates/dcim/device_import.html | 104 +--------- .../templates/dcim/device_import_child.html | 94 +-------- .../dcim/inc/device_import_header.html | 1 - netbox/templates/dcim/rack_import.html | 70 ------- netbox/templates/dcim/site_import.html | 81 -------- netbox/templates/utilities/obj_import.html | 1 + 8 files changed, 140 insertions(+), 423 deletions(-) delete mode 100644 netbox/templates/dcim/rack_import.html delete mode 100644 netbox/templates/dcim/site_import.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9e1cc657d..f1737b366 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -107,29 +107,34 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class SiteFromCSVForm(forms.ModelForm): +class SiteCSVForm(forms.ModelForm): region = forms.ModelChoiceField( - Region.objects.all(), to_field_name='name', required=False, error_messages={ - 'invalid_choice': 'Tenant not found.' + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned region', + error_messages={ + 'invalid_choice': 'Region not found.', } ) tenant = forms.ModelChoiceField( - Tenant.objects.all(), to_field_name='name', required=False, error_messages={ - 'invalid_choice': 'Tenant not found.' + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', } ) class Meta: model = Site fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', ] -class SiteImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=SiteFromCSVForm) - - class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) @@ -217,35 +222,62 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class RackFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - group_name = forms.CharField(required=False) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Role not found.'}) +class RackCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + group = forms.ModelChoiceField( + queryset=RackGroup.objects.all(), + to_field_name='name', + required=False, + help_text='Name of parent group', + error_messages={ + 'invalid_choice': 'Rack group not found.', + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + role = forms.ModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Role not found.', + } + ) type = forms.CharField(required=False) class Meta: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units'] + fields = [ + 'site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + ] - def clean(self): + def clean_group(self): site = self.cleaned_data.get('site') - group = self.cleaned_data.get('group_name') + group = self.cleaned_data.get('group') - # Validate rack group - if site and group: - try: - self.instance.group = RackGroup.objects.get(site=site, name=group) - except RackGroup.DoesNotExist: - self.add_error('group_name', "Invalid rack group ({})".format(group)) + if group and group.site != site: + raise ValidationError("Invalid group for site {}: {}".format(site, group)) def clean_type(self): + rack_type = self.cleaned_data['type'] + if not rack_type: return None try: @@ -258,10 +290,6 @@ class RackFromCSVForm(forms.ModelForm): )) -class RackImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=RackFromCSVForm) - - class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') @@ -663,25 +691,47 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceFromCSVForm(forms.ModelForm): +class BaseDeviceCSVForm(forms.ModelForm): device_role = forms.ModelChoiceField( - queryset=DeviceRole.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid device role.'} + queryset=DeviceRole.objects.all(), + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Invalid device role.', + } ) tenant = forms.ModelChoiceField( - Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'} + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } ) manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid manufacturer.'} + queryset=Manufacturer.objects.all(), + to_field_name='name', + help_text='Manufacturer name', + error_messages={ + 'invalid_choice': 'Invalid manufacturer.', + } + ) + model_name = forms.CharField( + help_text='Model name' ) - model_name = forms.CharField() platform = forms.ModelChoiceField( - queryset=Platform.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid platform.'} + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned platform', + error_messages={ + 'invalid_choice': 'Invalid platform.', + } + ) + status = forms.CharField( + help_text='Status name' ) - status = forms.CharField() class Meta: fields = [] @@ -707,16 +757,25 @@ class BaseDeviceFromCSVForm(forms.ModelForm): raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) -class DeviceFromCSVForm(BaseDeviceFromCSVForm): +class DeviceCSVForm(BaseDeviceCSVForm): site = forms.ModelChoiceField( - queryset=Site.objects.all(), to_field_name='name', error_messages={ + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ 'invalid_choice': 'Invalid site name.', } ) - rack_name = forms.CharField(required=False) - face = forms.CharField(required=False) + rack_name = forms.CharField( + required=False, + help_text='Name of parent rack' + ) + face = forms.CharField( + required=False, + help_text='Mounted rack face (front or rear)' + ) - class Meta(BaseDeviceFromCSVForm.Meta): + class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_name', 'position', 'face', @@ -724,7 +783,7 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): def clean(self): - super(DeviceFromCSVForm, self).clean() + super(DeviceCSVForm, self).clean() site = self.cleaned_data.get('site') rack_name = self.cleaned_data.get('rack_name') @@ -749,18 +808,20 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face)) -class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): +class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - required=False, + help_text='Name of parent device', error_messages={ - 'invalid_choice': 'Parent device not found.' + 'invalid_choice': 'Parent device not found.', } ) - device_bay_name = forms.CharField(required=False) + device_bay_name = forms.CharField( + help_text='Name of device bay', + ) - class Meta(BaseDeviceFromCSVForm.Meta): + class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'parent', 'device_bay_name', @@ -768,7 +829,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): def clean(self): - super(ChildDeviceFromCSVForm, self).clean() + super(ChildDeviceCSVForm, self).clean() parent = self.cleaned_data.get('parent') device_bay_name = self.cleaned_data.get('device_bay_name') @@ -778,20 +839,15 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): try: device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name) if device_bay.installed_device: - self.add_error('device_bay_name', - "Device bay ({} {}) is already occupied".format(parent, device_bay_name)) + self.add_error( + 'device_bay_name', "Device bay ({} {}) is already occupied".format(parent, device_bay_name) + ) else: self.instance.parent_bay = device_bay except DeviceBay.DoesNotExist: - self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name)) - - -class DeviceImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=DeviceFromCSVForm) - - -class ChildDeviceImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) + self.add_error( + 'device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name) + ) class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f6e00be04..888a54aaf 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,14 +23,14 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, BulkImportView2, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, Region, Site, ) @@ -217,11 +217,10 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:site_list' -class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): +class SiteBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'dcim.add_site' - form = forms.SiteImportForm + model_form = forms.SiteCSVForm table = tables.SiteTable - template_name = 'dcim/site_import.html' default_return_url = 'dcim:site_list' @@ -388,11 +387,10 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:rack_list' -class RackBulkImportView(PermissionRequiredMixin, BulkImportView): +class RackBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'dcim.add_rack' - form = forms.RackImportForm + model_form = forms.RackCSVForm table = tables.RackImportTable - template_name = 'dcim/rack_import.html' default_return_url = 'dcim:rack_list' @@ -864,17 +862,17 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:device_list' -class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): +class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'dcim.add_device' - form = forms.DeviceImportForm + model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' default_return_url = 'dcim:device_list' -class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): +class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'dcim.add_device' - form = forms.ChildDeviceImportForm + model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' default_return_url = 'dcim:device_list' diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index 8a1cfa1a5..85ebfbbc6 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -1,103 +1,5 @@ -{% extends '_base.html' %} -{% load form_helpers %} +{% extends 'utilities/obj_import.html' %} -{% block title %}Device Import{% endblock %} - -{% block content %} -{% include 'dcim/inc/device_import_header.html' %} -
-
-
- {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel - {% endif %} -
-
-
-

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameDevice name (optional)rack101_sw1
Device roleFunctional role of deviceToR Switch
TenantName of tenant (optional)Pied Piper
Device manufacturerHardware manufacturerJuniper
Device modelHardware modelEX4300-48T
PlatformSoftware running on device (optional)Juniper Junos
Serial numberPhysical serial number (optional)CAB00577291
Asset tagUnique alphanumeric tag (optional)ABC123456
StatusCurrent statusActive
SiteSite nameAshburn-VA
RackRack name (optional)R101
Position (U)Lowest-numbered rack unit occupied by the device (optional)21
FaceRack face; front or rear (required if position is set)Rear
-

Example

-
rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Active,Ashburn-VA,R101,21,Rear
-
-
+{% block tabs %} + {% include 'dcim/inc/device_import_header.html' %} {% endblock %} diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html index 668a9c810..406d239d7 100644 --- a/netbox/templates/dcim/device_import_child.html +++ b/netbox/templates/dcim/device_import_child.html @@ -1,93 +1,5 @@ -{% extends '_base.html' %} -{% load form_helpers %} +{% extends 'utilities/obj_import.html' %} -{% block title %}Device Import{% endblock %} - -{% block content %} -{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} -
-
-
- {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel - {% endif %} -
-
-
-

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameDevice name (optional)Blade12
Device roleFunctional role of deviceBlade Server
TenantName of tenant (optional)Pied Piper
Device manufacturerHardware manufacturerDell
Device modelHardware modelBS2000T
PlatformSoftware running on device (optional)Linux
Serial numberPhysical serial number (optional)CAB00577291
Asset tagUnique alphanumeric tag (optional)ABC123456
StatusCurrent statusActive
Parent deviceParent deviceServer101
Device bayDevice bay nameSlot 4
-

Example

-
Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Active,Server101,Slot4
-
-
+{% block tabs %} + {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} {% endblock %} diff --git a/netbox/templates/dcim/inc/device_import_header.html b/netbox/templates/dcim/inc/device_import_header.html index 57dd1b46e..2adc867b1 100644 --- a/netbox/templates/dcim/inc/device_import_header.html +++ b/netbox/templates/dcim/inc/device_import_header.html @@ -1,4 +1,3 @@ -

Device Import