Parcourir la source

Merge branch 'import_headers' into develop

Jeremy Stretch il y a 7 ans
Parent
commit
e06221bc89
35 fichiers modifiés avec 841 ajouts et 1519 suppressions
  1. 38 20
      netbox/circuits/forms.py
  2. 2 4
      netbox/circuits/views.py
  3. 352 304
      netbox/dcim/forms.py
  4. 8 5
      netbox/dcim/models.py
  5. 1 1
      netbox/dcim/tables.py
  6. 14 20
      netbox/dcim/views.py
  7. 181 125
      netbox/ipam/forms.py
  8. 1 3
      netbox/ipam/models.py
  9. 5 10
      netbox/ipam/views.py
  10. 25 12
      netbox/secrets/forms.py
  11. 1 1
      netbox/secrets/urls.py
  12. 35 41
      netbox/secrets/views.py
  13. 0 55
      netbox/templates/circuits/circuit_import.html
  14. 0 45
      netbox/templates/circuits/provider_import.html
  15. 0 45
      netbox/templates/dcim/console_connections_import.html
  16. 3 101
      netbox/templates/dcim/device_import.html
  17. 3 91
      netbox/templates/dcim/device_import_child.html
  18. 0 1
      netbox/templates/dcim/inc/device_import_header.html
  19. 0 45
      netbox/templates/dcim/interface_connections_import.html
  20. 0 45
      netbox/templates/dcim/power_connections_import.html
  21. 0 70
      netbox/templates/dcim/rack_import.html
  22. 0 81
      netbox/templates/dcim/site_import.html
  23. 0 40
      netbox/templates/ipam/aggregate_import.html
  24. 0 60
      netbox/templates/ipam/ipaddress_import.html
  25. 0 70
      netbox/templates/ipam/prefix_import.html
  26. 0 60
      netbox/templates/ipam/vlan_import.html
  27. 0 45
      netbox/templates/ipam/vrf_import.html
  28. 0 40
      netbox/templates/tenancy/tenant_import.html
  29. 31 4
      netbox/templates/utilities/obj_import.html
  30. 17 10
      netbox/tenancy/forms.py
  31. 1 2
      netbox/tenancy/views.py
  32. 51 42
      netbox/utilities/forms.py
  33. 4 2
      netbox/utilities/templatetags/form_helpers.py
  34. 18 0
      netbox/utilities/templatetags/helpers.py
  35. 50 19
      netbox/utilities/views.py

+ 38 - 20
netbox/circuits/forms.py

@@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 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
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -39,15 +39,18 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
         }
         }
 
 
 
 
-class ProviderFromCSVForm(forms.ModelForm):
+class ProviderCSVForm(forms.ModelForm):
+    slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = Provider
         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': '32-bit autonomous system number',
+            'portal_url': 'Portal URL',
+            'comments': 'Free-form comments',
+        }
 
 
 
 
 class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -102,21 +105,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='Type of circuit',
+        error_messages={
+            'invalid_choice': 'Invalid circuit type.'
+        }
+    )
+    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.'
+        }
+    )
 
 
     class Meta:
     class Meta:
         model = Circuit
         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):
 class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 2 - 4
netbox/circuits/views.py

@@ -65,9 +65,8 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'circuits.add_provider'
     permission_required = 'circuits.add_provider'
-    form = forms.ProviderImportForm
+    model_form = forms.ProviderCSVForm
     table = tables.ProviderTable
     table = tables.ProviderTable
-    template_name = 'circuits/provider_import.html'
     default_return_url = 'circuits:provider_list'
     default_return_url = 'circuits:provider_list'
 
 
 
 
@@ -163,9 +162,8 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
 class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'circuits.add_circuit'
     permission_required = 'circuits.add_circuit'
-    form = forms.CircuitImportForm
+    model_form = forms.CircuitCSVForm
     table = tables.CircuitTable
     table = tables.CircuitTable
-    template_name = 'circuits/circuit_import.html'
     default_return_url = 'circuits:circuit_list'
     default_return_url = 'circuits:circuit_list'
 
 
 
 

+ 352 - 304
netbox/dcim/forms.py

@@ -5,7 +5,6 @@ import re
 
 
 from django import forms
 from django import forms
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.contrib.postgres.forms.array import SimpleArrayField
-from django.core.exceptions import ValidationError
 from django.db.models import Count, Q
 from django.db.models import Count, Q
 
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@@ -14,18 +13,18 @@ from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField,
-    FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVChoiceField, ExpandableNameField, FilterChoiceField,
+    FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
     FilterTreeNodeMultipleChoiceField,
     FilterTreeNodeMultipleChoiceField,
 )
 )
 from .formfields import MACAddressFormField
 from .formfields import MACAddressFormField
 from .models import (
 from .models import (
-    DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
-    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
-    Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
-    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate,
-    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES,
-    SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
+    DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
+    ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface,
+    IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer,
+    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES,
+    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN,
+    Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
 )
 )
 
 
 
 
@@ -50,14 +49,6 @@ def get_device_by_name_or_pk(name):
     return device
     return device
 
 
 
 
-def validate_connection_status(value):
-    """
-    Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
-    """
-    if value.lower() not in ['planned', 'connected']:
-        raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
-
-
 class DeviceComponentForm(BootstrapMixin, forms.Form):
 class DeviceComponentForm(BootstrapMixin, forms.Form):
     """
     """
     Allow inclusion of the parent device as context for limiting field choices.
     Allow inclusion of the parent device as context for limiting field choices.
@@ -107,27 +98,37 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class SiteFromCSVForm(forms.ModelForm):
+class SiteCSVForm(forms.ModelForm):
     region = forms.ModelChoiceField(
     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 = 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:
     class Meta:
         model = Site
         model = Site
         fields = [
         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)
+        help_texts = {
+            'name': 'Site name',
+            'slug': 'URL-friendly slug',
+            'asn': '32-bit autonomous system number',
+        }
 
 
 
 
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -217,49 +218,73 @@ 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.'})
-    type = forms.CharField(required=False)
+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_name = forms.CharField(
+        help_text='Name of rack group',
+        required=False
+    )
+    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 = CSVChoiceField(
+        choices=RACK_TYPE_CHOICES,
+        required=False,
+        help_text='Rack type'
+    )
+    width = forms.ChoiceField(
+        choices = (
+            (RACK_WIDTH_19IN, '19'),
+            (RACK_WIDTH_23IN, '23'),
+        ),
+        help_text='Rail-to-rail width (in inches)'
+    )
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height',
-                  'desc_units']
+        fields = [
+            'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
+        ]
+        help_texts = {
+            'name': 'Rack name',
+            'u_height': 'Height in rack units',
+        }
 
 
     def clean(self):
     def clean(self):
 
 
+        super(RackCSVForm, self).clean()
+
         site = self.cleaned_data.get('site')
         site = self.cleaned_data.get('site')
-        group = self.cleaned_data.get('group_name')
+        group_name = self.cleaned_data.get('group_name')
 
 
         # Validate rack group
         # Validate rack group
-        if site and group:
+        if group_name:
             try:
             try:
-                self.instance.group = RackGroup.objects.get(site=site, name=group)
+                self.instance.group = RackGroup.objects.get(site=site, name=group_name)
             except RackGroup.DoesNotExist:
             except RackGroup.DoesNotExist:
-                self.add_error('group_name', "Invalid rack group ({})".format(group))
-
-    def clean_type(self):
-        rack_type = self.cleaned_data['type']
-        if not rack_type:
-            return None
-        try:
-            choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES}
-            return choices[rack_type.lower()]
-        except KeyError:
-            raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format(
-                rack_type,
-                ', '.join({v: k for k, v in RACK_TYPE_CHOICES}),
-            ))
-
-
-class RackImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=RackFromCSVForm)
+                raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site))
 
 
 
 
 class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -663,32 +688,60 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             self.initial['rack'] = self.instance.parent_bay.device.rack_id
             self.initial['rack'] = self.instance.parent_bay.device.rack_id
 
 
 
 
-class BaseDeviceFromCSVForm(forms.ModelForm):
+class BaseDeviceCSVForm(forms.ModelForm):
     device_role = forms.ModelChoiceField(
     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 = 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(
     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='Device type manufacturer',
+        error_messages={
+            'invalid_choice': 'Invalid manufacturer.',
+        }
+    )
+    model_name = forms.CharField(
+        help_text='Device type model name'
     )
     )
-    model_name = forms.CharField()
     platform = forms.ModelChoiceField(
     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 = CSVChoiceField(
+        choices=STATUS_CHOICES,
+        help_text='Operational status of device'
     )
     )
-    status = forms.CharField()
 
 
     class Meta:
     class Meta:
         fields = []
         fields = []
         model = Device
         model = Device
+        help_texts = {
+            'name': 'Device name',
+        }
 
 
     def clean(self):
     def clean(self):
 
 
+        super(BaseDeviceCSVForm, self).clean()
+
         manufacturer = self.cleaned_data.get('manufacturer')
         manufacturer = self.cleaned_data.get('manufacturer')
         model_name = self.cleaned_data.get('model_name')
         model_name = self.cleaned_data.get('model_name')
 
 
@@ -697,70 +750,73 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
             try:
             try:
                 self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
                 self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
             except DeviceType.DoesNotExist:
             except DeviceType.DoesNotExist:
-                self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
-
-    def clean_status(self):
-        status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES}
-        try:
-            return status_choices[self.cleaned_data['status'].lower()]
-        except KeyError:
-            raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
+                raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name))
 
 
 
 
-class DeviceFromCSVForm(BaseDeviceFromCSVForm):
+class DeviceCSVForm(BaseDeviceCSVForm):
     site = forms.ModelChoiceField(
     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.',
             'invalid_choice': 'Invalid site name.',
         }
         }
     )
     )
-    rack_name = forms.CharField(required=False)
-    face = forms.CharField(required=False)
+    rack_group = forms.CharField(
+        required=False,
+        help_text='Parent rack\'s group (if any)'
+    )
+    rack_name = forms.CharField(
+        required=False,
+        help_text='Name of parent rack'
+    )
+    face = CSVChoiceField(
+        choices=RACK_FACE_CHOICES,
+        required=False,
+        help_text='Mounted rack face'
+    )
 
 
-    class Meta(BaseDeviceFromCSVForm.Meta):
+    class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'site', 'rack_name', 'position', 'face',
+            'site', 'rack_group', 'rack_name', 'position', 'face',
         ]
         ]
 
 
     def clean(self):
     def clean(self):
 
 
-        super(DeviceFromCSVForm, self).clean()
+        super(DeviceCSVForm, self).clean()
 
 
         site = self.cleaned_data.get('site')
         site = self.cleaned_data.get('site')
+        rack_group = self.cleaned_data.get('rack_group')
         rack_name = self.cleaned_data.get('rack_name')
         rack_name = self.cleaned_data.get('rack_name')
 
 
         # Validate rack
         # Validate rack
-        if site and rack_name:
+        if site and rack_group and rack_name:
             try:
             try:
-                self.instance.rack = Rack.objects.get(site=site, name=rack_name)
+                self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
             except Rack.DoesNotExist:
             except Rack.DoesNotExist:
-                self.add_error('rack_name', "Invalid rack ({})".format(rack_name))
-
-    def clean_face(self):
-        face = self.cleaned_data['face']
-        if not face:
-            return None
-        try:
-            return {
-                'front': 0,
-                'rear': 1,
-            }[face.lower()]
-        except KeyError:
-            raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
+                raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group))
+        elif site and rack_name:
+            try:
+                self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name)
+            except Rack.DoesNotExist:
+                raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site))
 
 
 
 
-class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
+class ChildDeviceCSVForm(BaseDeviceCSVForm):
     parent = FlexibleModelChoiceField(
     parent = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        required=False,
+        help_text='Name or ID of parent device',
         error_messages={
         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 = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             'parent', 'device_bay_name',
             'parent', 'device_bay_name',
@@ -768,7 +824,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
 
 
     def clean(self):
     def clean(self):
 
 
-        super(ChildDeviceFromCSVForm, self).clean()
+        super(ChildDeviceCSVForm, self).clean()
 
 
         parent = self.cleaned_data.get('parent')
         parent = self.cleaned_data.get('parent')
         device_bay_name = self.cleaned_data.get('device_bay_name')
         device_bay_name = self.cleaned_data.get('device_bay_name')
@@ -776,22 +832,12 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
         # Validate device bay
         # Validate device bay
         if parent and device_bay_name:
         if parent and device_bay_name:
             try:
             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))
-                else:
-                    self.instance.parent_bay = device_bay
+                self.instance.parent_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
+                # Inherit site and rack from parent device
+                self.instance.site = parent.site
+                self.instance.rack = parent.rack
             except DeviceBay.DoesNotExist:
             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)
+                raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name))
 
 
 
 
 class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -889,75 +935,84 @@ class ConsolePortCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
-class ConsoleConnectionCSVForm(forms.Form):
+class ConsoleConnectionCSVForm(forms.ModelForm):
     console_server = FlexibleModelChoiceField(
     console_server = FlexibleModelChoiceField(
         queryset=Device.objects.filter(device_type__is_console_server=True),
         queryset=Device.objects.filter(device_type__is_console_server=True),
         to_field_name='name',
         to_field_name='name',
+        help_text='Console server name or ID',
         error_messages={
         error_messages={
             'invalid_choice': 'Console server not found',
             'invalid_choice': 'Console server not found',
         }
         }
     )
     )
-    cs_port = forms.CharField()
-    device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
-                                      error_messages={'invalid_choice': 'Device not found'})
-    console_port = forms.CharField()
-    status = forms.CharField(validators=[validate_connection_status])
+    cs_port = forms.CharField(
+        help_text='Console server port name'
+    )
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Device name or ID',
+        error_messages={
+            'invalid_choice': 'Device not found',
+        }
+    )
+    console_port = forms.CharField(
+        help_text='Console port name'
+    )
+    connection_status = CSVChoiceField(
+        choices=CONNECTION_STATUS_CHOICES,
+        help_text='Connection status'
+    )
 
 
-    def clean(self):
+    class Meta:
+        model = ConsolePort
+        fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status']
 
 
-        # Validate console server port
-        if self.cleaned_data.get('console_server'):
-            try:
-                cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'],
-                                                        name=self.cleaned_data['cs_port'])
-                if ConsolePort.objects.filter(cs_port=cs_port):
-                    raise forms.ValidationError("Console server port is already occupied (by {} {})"
-                                                .format(cs_port.connected_console.device, cs_port.connected_console))
-            except ConsoleServerPort.DoesNotExist:
-                raise forms.ValidationError("Invalid console server port ({} {})"
-                                            .format(self.cleaned_data['console_server'], self.cleaned_data['cs_port']))
-
-        # Validate console port
-        if self.cleaned_data.get('device'):
-            try:
-                console_port = ConsolePort.objects.get(device=self.cleaned_data['device'],
-                                                       name=self.cleaned_data['console_port'])
-                if console_port.cs_port:
-                    raise forms.ValidationError("Console port is already connected (to {} {})"
-                                                .format(console_port.cs_port.device, console_port.cs_port))
-            except ConsolePort.DoesNotExist:
-                raise forms.ValidationError("Invalid console port ({} {})"
-                                            .format(self.cleaned_data['device'], self.cleaned_data['console_port']))
+    def clean_console_port(self):
 
 
+        console_port_name = self.cleaned_data.get('console_port')
+        if not self.cleaned_data.get('device') or not console_port_name:
+            return None
 
 
-class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
+        try:
+            # Retrieve console port by name
+            consoleport = ConsolePort.objects.get(
+                device=self.cleaned_data['device'], name=console_port_name
+            )
+            # Check if the console port is already connected
+            if consoleport.cs_port is not None:
+                raise forms.ValidationError("{} {} is already connected".format(
+                    self.cleaned_data['device'], console_port_name
+                ))
+        except ConsolePort.DoesNotExist:
+            raise forms.ValidationError("Invalid console port ({} {})".format(
+                self.cleaned_data['device'], console_port_name
+            ))
 
 
-    def clean(self):
-        records = self.cleaned_data.get('csv')
-        if not records:
-            return
-
-        connection_list = []
-
-        for i, record in enumerate(records, start=1):
-            form = self.fields['csv'].csv_form(data=record)
-            if form.is_valid():
-                console_port = ConsolePort.objects.get(device=form.cleaned_data['device'],
-                                                       name=form.cleaned_data['console_port'])
-                console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'],
-                                                                     name=form.cleaned_data['cs_port'])
-                if form.cleaned_data['status'] == 'planned':
-                    console_port.connection_status = CONNECTION_STATUS_PLANNED
-                else:
-                    console_port.connection_status = CONNECTION_STATUS_CONNECTED
-                connection_list.append(console_port)
-            else:
-                for field, errors in form.errors.items():
-                    for e in errors:
-                        self.add_error('csv', "Record {} {}: {}".format(i, field, e))
+        self.instance = consoleport
+        return consoleport
+
+    def clean_cs_port(self):
+
+        cs_port_name = self.cleaned_data.get('cs_port')
+        if not self.cleaned_data.get('console_server') or not cs_port_name:
+            return None
+
+        try:
+            # Retrieve console server port by name
+            cs_port = ConsoleServerPort.objects.get(
+                device=self.cleaned_data['console_server'], name=cs_port_name
+            )
+            # Check if the console server port is already connected
+            if ConsolePort.objects.filter(cs_port=cs_port).count():
+                raise forms.ValidationError("{} {} is already connected".format(
+                    self.cleaned_data['console_server'], cs_port_name
+                ))
+        except ConsoleServerPort.DoesNotExist:
+            raise forms.ValidationError("Invalid console server port ({} {})".format(
+                self.cleaned_data['console_server'], cs_port_name
+            ))
 
 
-        self.cleaned_data['csv'] = connection_list
+        return cs_port
 
 
 
 
 class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
 class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
@@ -1137,76 +1192,84 @@ class PowerPortCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
-class PowerConnectionCSVForm(forms.Form):
+class PowerConnectionCSVForm(forms.ModelForm):
     pdu = FlexibleModelChoiceField(
     pdu = FlexibleModelChoiceField(
         queryset=Device.objects.filter(device_type__is_pdu=True),
         queryset=Device.objects.filter(device_type__is_pdu=True),
         to_field_name='name',
         to_field_name='name',
+        help_text='PDU name or ID',
         error_messages={
         error_messages={
             'invalid_choice': 'PDU not found.',
             'invalid_choice': 'PDU not found.',
         }
         }
     )
     )
-    power_outlet = forms.CharField()
-    device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
-                                      error_messages={'invalid_choice': 'Device not found'})
-    power_port = forms.CharField()
-    status = forms.CharField(validators=[validate_connection_status])
+    power_outlet = forms.CharField(
+        help_text='Power outlet name'
+    )
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Device name or ID',
+        error_messages={
+            'invalid_choice': 'Device not found',
+        }
+    )
+    power_port = forms.CharField(
+        help_text='Power port name'
+    )
+    connection_status = CSVChoiceField(
+        choices=CONNECTION_STATUS_CHOICES,
+        help_text='Connection status'
+    )
 
 
-    def clean(self):
+    class Meta:
+        model = PowerPort
+        fields = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status']
 
 
-        # Validate power outlet
-        if self.cleaned_data.get('pdu'):
-            try:
-                power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'],
-                                                       name=self.cleaned_data['power_outlet'])
-                if PowerPort.objects.filter(power_outlet=power_outlet):
-                    raise forms.ValidationError("Power outlet is already occupied (by {} {})"
-                                                .format(power_outlet.connected_port.device,
-                                                        power_outlet.connected_port))
-            except PowerOutlet.DoesNotExist:
-                raise forms.ValidationError("Invalid PDU port ({} {})"
-                                            .format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet']))
-
-        # Validate power port
-        if self.cleaned_data.get('device'):
-            try:
-                power_port = PowerPort.objects.get(device=self.cleaned_data['device'],
-                                                   name=self.cleaned_data['power_port'])
-                if power_port.power_outlet:
-                    raise forms.ValidationError("Power port is already connected (to {} {})"
-                                                .format(power_port.power_outlet.device, power_port.power_outlet))
-            except PowerPort.DoesNotExist:
-                raise forms.ValidationError("Invalid power port ({} {})"
-                                            .format(self.cleaned_data['device'], self.cleaned_data['power_port']))
+    def clean_power_port(self):
 
 
+        power_port_name = self.cleaned_data.get('power_port')
+        if not self.cleaned_data.get('device') or not power_port_name:
+            return None
 
 
-class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=PowerConnectionCSVForm)
+        try:
+            # Retrieve power port by name
+            powerport = PowerPort.objects.get(
+                device=self.cleaned_data['device'], name=power_port_name
+            )
+            # Check if the power port is already connected
+            if powerport.power_outlet is not None:
+                raise forms.ValidationError("{} {} is already connected".format(
+                    self.cleaned_data['device'], power_port_name
+                ))
+        except PowerPort.DoesNotExist:
+            raise forms.ValidationError("Invalid power port ({} {})".format(
+                self.cleaned_data['device'], power_port_name
+            ))
 
 
-    def clean(self):
-        records = self.cleaned_data.get('csv')
-        if not records:
-            return
-
-        connection_list = []
-
-        for i, record in enumerate(records, start=1):
-            form = self.fields['csv'].csv_form(data=record)
-            if form.is_valid():
-                power_port = PowerPort.objects.get(device=form.cleaned_data['device'],
-                                                   name=form.cleaned_data['power_port'])
-                power_port.power_outlet = PowerOutlet.objects.get(device=form.cleaned_data['pdu'],
-                                                                  name=form.cleaned_data['power_outlet'])
-                if form.cleaned_data['status'] == 'planned':
-                    power_port.connection_status = CONNECTION_STATUS_PLANNED
-                else:
-                    power_port.connection_status = CONNECTION_STATUS_CONNECTED
-                connection_list.append(power_port)
-            else:
-                for field, errors in form.errors.items():
-                    for e in errors:
-                        self.add_error('csv', "Record {} {}: {}".format(i, field, e))
+        self.instance = powerport
+        return powerport
 
 
-        self.cleaned_data['csv'] = connection_list
+    def clean_power_outlet(self):
+
+        power_outlet_name = self.cleaned_data.get('power_outlet')
+        if not self.cleaned_data.get('pdu') or not power_outlet_name:
+            return None
+
+        try:
+            # Retrieve power outlet by name
+            power_outlet = PowerOutlet.objects.get(
+                device=self.cleaned_data['pdu'], name=power_outlet_name
+            )
+            # Check if the power outlet is already connected
+            if PowerPort.objects.filter(power_outlet=power_outlet).count():
+                raise forms.ValidationError("{} {} is already connected".format(
+                    self.cleaned_data['pdu'], power_outlet_name
+                ))
+        except PowerOutlet.DoesNotExist:
+            raise forms.ValidationError("Invalid power outlet ({} {})".format(
+                self.cleaned_data['pdu'], power_outlet_name
+            ))
+
+        return power_outlet
 
 
 
 
 class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
 class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
@@ -1536,94 +1599,79 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         ]
         ]
 
 
 
 
-class InterfaceConnectionCSVForm(forms.Form):
+class InterfaceConnectionCSVForm(forms.ModelForm):
     device_a = FlexibleModelChoiceField(
     device_a = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
+        help_text='Name or ID of device A',
         error_messages={'invalid_choice': 'Device A not found.'}
         error_messages={'invalid_choice': 'Device A not found.'}
     )
     )
-    interface_a = forms.CharField()
+    interface_a = forms.CharField(
+        help_text='Name of interface A'
+    )
     device_b = FlexibleModelChoiceField(
     device_b = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
+        help_text='Name or ID of device B',
         error_messages={'invalid_choice': 'Device B not found.'}
         error_messages={'invalid_choice': 'Device B not found.'}
     )
     )
-    interface_b = forms.CharField()
-    status = forms.CharField(
-        validators=[validate_connection_status]
+    interface_b = forms.CharField(
+        help_text='Name of interface B'
+    )
+    connection_status = CSVChoiceField(
+        choices=CONNECTION_STATUS_CHOICES,
+        help_text='Connection status'
     )
     )
 
 
-    def clean(self):
+    class Meta:
+        model = InterfaceConnection
+        fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
 
 
-        # Validate interface A
-        if self.cleaned_data.get('device_a'):
-            try:
-                interface_a = Interface.objects.get(device=self.cleaned_data['device_a'],
-                                                    name=self.cleaned_data['interface_a'])
-            except Interface.DoesNotExist:
-                raise forms.ValidationError("Invalid interface ({} {})"
-                                            .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
-            try:
-                InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a))
-                raise forms.ValidationError("{} {} is already connected"
-                                            .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
-            except InterfaceConnection.DoesNotExist:
-                pass
+    def clean_interface_a(self):
 
 
-        # Validate interface B
-        if self.cleaned_data.get('device_b'):
-            try:
-                interface_b = Interface.objects.get(device=self.cleaned_data['device_b'],
-                                                    name=self.cleaned_data['interface_b'])
-            except Interface.DoesNotExist:
-                raise forms.ValidationError("Invalid interface ({} {})"
-                                            .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
-            try:
-                InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b))
-                raise forms.ValidationError("{} {} is already connected"
-                                            .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
-            except InterfaceConnection.DoesNotExist:
-                pass
+        interface_name = self.cleaned_data.get('interface_a')
+        if not interface_name:
+            return None
+
+        try:
+            # Retrieve interface by name
+            interface = Interface.objects.get(
+                device=self.cleaned_data['device_a'], name=interface_name
+            )
+            # Check for an existing connection to this interface
+            if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count():
+                raise forms.ValidationError("{} {} is already connected".format(
+                    self.cleaned_data['device_a'], interface_name
+                ))
+        except Interface.DoesNotExist:
+            raise forms.ValidationError("Invalid interface ({} {})".format(
+                self.cleaned_data['device_a'], interface_name
+            ))
 
 
+        return interface
 
 
-class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
+    def clean_interface_b(self):
 
 
-    def clean(self):
-        records = self.cleaned_data.get('csv')
-        if not records:
-            return
-
-        connection_list = []
-        occupied_interfaces = []
-
-        for i, record in enumerate(records, start=1):
-            form = self.fields['csv'].csv_form(data=record)
-            if form.is_valid():
-                interface_a = Interface.objects.get(device=form.cleaned_data['device_a'],
-                                                    name=form.cleaned_data['interface_a'])
-                if interface_a in occupied_interfaces:
-                    raise forms.ValidationError("{} {} found in multiple connections"
-                                                .format(interface_a.device.name, interface_a.name))
-                interface_b = Interface.objects.get(device=form.cleaned_data['device_b'],
-                                                    name=form.cleaned_data['interface_b'])
-                if interface_b in occupied_interfaces:
-                    raise forms.ValidationError("{} {} found in multiple connections"
-                                                .format(interface_b.device.name, interface_b.name))
-                connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b)
-                if form.cleaned_data['status'] == 'planned':
-                    connection.connection_status = CONNECTION_STATUS_PLANNED
-                else:
-                    connection.connection_status = CONNECTION_STATUS_CONNECTED
-                connection_list.append(connection)
-                occupied_interfaces.append(interface_a)
-                occupied_interfaces.append(interface_b)
-            else:
-                for field, errors in form.errors.items():
-                    for e in errors:
-                        self.add_error('csv', "Record {} {}: {}".format(i, field, e))
+        interface_name = self.cleaned_data.get('interface_b')
+        if not interface_name:
+            return None
+
+        try:
+            # Retrieve interface by name
+            interface = Interface.objects.get(
+                device=self.cleaned_data['device_b'], name=interface_name
+            )
+            # Check for an existing connection to this interface
+            if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count():
+                raise forms.ValidationError("{} {} is already connected".format(
+                    self.cleaned_data['device_b'], interface_name
+                ))
+        except Interface.DoesNotExist:
+            raise forms.ValidationError("Invalid interface ({} {})".format(
+                self.cleaned_data['device_b'], interface_name
+            ))
 
 
-        self.cleaned_data['csv'] = connection_list
+        return interface
 
 
 
 
 class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):
 class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):

+ 8 - 5
netbox/dcim/models.py

@@ -346,7 +346,7 @@ class RackGroup(models.Model):
         ]
         ]
 
 
     def __str__(self):
     def __str__(self):
-        return '{} - {}'.format(self.site.name, self.name)
+        return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@@ -1393,10 +1393,13 @@ class InterfaceConnection(models.Model):
                                             verbose_name='Status')
                                             verbose_name='Status')
 
 
     def clean(self):
     def clean(self):
-        if self.interface_a == self.interface_b:
-            raise ValidationError({
-                'interface_b': "Cannot connect an interface to itself."
-            })
+        try:
+            if self.interface_a == self.interface_b:
+                raise ValidationError({
+                    'interface_b': "Cannot connect an interface to itself."
+                })
+        except ObjectDoesNotExist:
+            pass
 
 
     # Used for connections export
     # Used for connections export
     def to_csv(self):
     def to_csv(self):

+ 1 - 1
netbox/dcim/tables.py

@@ -247,7 +247,7 @@ class RackImportTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         model = Rack
-        fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
+        fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height')
 
 
 
 
 #
 #

+ 14 - 20
netbox/dcim/views.py

@@ -29,8 +29,8 @@ from . import filters, forms, tables
 from .models import (
 from .models import (
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
     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,
 )
 )
 
 
 
 
@@ -219,9 +219,8 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
 class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.add_site'
     permission_required = 'dcim.add_site'
-    form = forms.SiteImportForm
+    model_form = forms.SiteCSVForm
     table = tables.SiteTable
     table = tables.SiteTable
-    template_name = 'dcim/site_import.html'
     default_return_url = 'dcim:site_list'
     default_return_url = 'dcim:site_list'
 
 
 
 
@@ -390,9 +389,8 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
 class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.add_rack'
     permission_required = 'dcim.add_rack'
-    form = forms.RackImportForm
+    model_form = forms.RackCSVForm
     table = tables.RackImportTable
     table = tables.RackImportTable
-    template_name = 'dcim/rack_import.html'
     default_return_url = 'dcim:rack_list'
     default_return_url = 'dcim:rack_list'
 
 
 
 
@@ -866,7 +864,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.add_device'
     permission_required = 'dcim.add_device'
-    form = forms.DeviceImportForm
+    model_form = forms.DeviceCSVForm
     table = tables.DeviceImportTable
     table = tables.DeviceImportTable
     template_name = 'dcim/device_import.html'
     template_name = 'dcim/device_import.html'
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
@@ -874,23 +872,22 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.add_device'
     permission_required = 'dcim.add_device'
-    form = forms.ChildDeviceImportForm
+    model_form = forms.ChildDeviceCSVForm
     table = tables.DeviceImportTable
     table = tables.DeviceImportTable
     template_name = 'dcim/device_import_child.html'
     template_name = 'dcim/device_import_child.html'
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
-    def save_obj(self, obj):
+    def _save_obj(self, obj_form):
 
 
-        # Inherit site and rack from parent device
-        obj.site = obj.parent_bay.device.site
-        obj.rack = obj.parent_bay.device.rack
-        obj.save()
+        obj = obj_form.save()
 
 
-        # Save the reverse relation
+        # Save the reverse relation to the parent device bay
         device_bay = obj.parent_bay
         device_bay = obj.parent_bay
         device_bay.installed_device = obj
         device_bay.installed_device = obj
         device_bay.save()
         device_bay.save()
 
 
+        return obj
+
 
 
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_device'
     permission_required = 'dcim.change_device'
@@ -1016,9 +1013,8 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.change_consoleport'
     permission_required = 'dcim.change_consoleport'
-    form = forms.ConsoleConnectionImportForm
+    model_form = forms.ConsoleConnectionCSVForm
     table = tables.ConsoleConnectionTable
     table = tables.ConsoleConnectionTable
-    template_name = 'dcim/console_connections_import.html'
     default_return_url = 'dcim:console_connections_list'
     default_return_url = 'dcim:console_connections_list'
 
 
 
 
@@ -1239,9 +1235,8 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.change_powerport'
     permission_required = 'dcim.change_powerport'
-    form = forms.PowerConnectionImportForm
+    model_form = forms.PowerConnectionCSVForm
     table = tables.PowerConnectionTable
     table = tables.PowerConnectionTable
-    template_name = 'dcim/power_connections_import.html'
     default_return_url = 'dcim:power_connections_list'
     default_return_url = 'dcim:power_connections_list'
 
 
 
 
@@ -1676,9 +1671,8 @@ def interfaceconnection_delete(request, pk):
 
 
 class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
-    form = forms.InterfaceConnectionImportForm
+    model_form = forms.InterfaceConnectionCSVForm
     table = tables.InterfaceConnectionTable
     table = tables.InterfaceConnectionTable
-    template_name = 'dcim/interface_connections_import.html'
     default_return_url = 'dcim:interface_connections_list'
     default_return_url = 'dcim:interface_connections_list'
 
 
 
 

+ 181 - 125
netbox/ipam/forms.py

@@ -1,7 +1,6 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from django import forms
 from django import forms
-from django.core.exceptions import ValidationError
 from django.db.models import Count
 from django.db.models import Count
 
 
 from dcim.models import Site, Rack, Device, Interface
 from dcim.models import Site, Rack, Device, Interface
@@ -9,8 +8,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
-    ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
+    APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
+    ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
+    add_blank_choice,
 )
 )
 from .models import (
 from .models import (
     Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
     Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
@@ -48,17 +48,23 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class VRFFromCSVForm(forms.ModelForm):
-    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
-                                    error_messages={'invalid_choice': 'Tenant not found.'})
+class VRFCSVForm(forms.ModelForm):
+    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.',
+        }
+    )
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
         fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
-
-
-class VRFImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=VRFFromCSVForm)
+        help_texts = {
+            'name': 'VRF name',
+        }
 
 
 
 
 class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -116,19 +122,21 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
         }
         }
 
 
 
 
-class AggregateFromCSVForm(forms.ModelForm):
-    rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name',
-                                 error_messages={'invalid_choice': 'RIR not found.'})
+class AggregateCSVForm(forms.ModelForm):
+    rir = forms.ModelChoiceField(
+        queryset=RIR.objects.all(),
+        to_field_name='name',
+        help_text='Name of parent RIR',
+        error_messages={
+            'invalid_choice': 'RIR not found.',
+        }
+    )
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
         fields = ['prefix', 'rir', 'date_added', 'description']
         fields = ['prefix', 'rir', 'date_added', 'description']
 
 
 
 
-class AggregateImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=AggregateFromCSVForm)
-
-
 class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
     rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
     rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
@@ -197,69 +205,89 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
 
 
-class PrefixFromCSVForm(forms.ModelForm):
-    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
-                                 error_messages={'invalid_choice': 'VRF not found.'})
-    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
-                                    error_messages={'invalid_choice': 'Tenant not found.'})
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
-                                  error_messages={'invalid_choice': 'Site not found.'})
-    vlan_group_name = forms.CharField(required=False)
-    vlan_vid = forms.IntegerField(required=False)
-    status = forms.CharField()
-    role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
-                                  error_messages={'invalid_choice': 'Invalid role.'})
+class PrefixCSVForm(forms.ModelForm):
+    vrf = forms.ModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        to_field_name='rd',
+        help_text='Route distinguisher of parent VRF',
+        error_messages={
+            'invalid_choice': 'VRF 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.',
+        }
+    )
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of parent site',
+        error_messages={
+            'invalid_choice': 'Site not found.',
+        }
+    )
+    vlan_group = forms.CharField(
+        help_text='Group name of assigned VLAN',
+        required=False
+    )
+    vlan_vid = forms.IntegerField(
+        help_text='Numeric ID of assigned VLAN',
+        required=False
+    )
+    status = CSVChoiceField(
+        choices=IPADDRESS_STATUS_CHOICES,
+        help_text='Operational status'
+    )
+    role = forms.ModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Functional role',
+        error_messages={
+            'invalid_choice': 'Invalid role.',
+        }
+    )
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         fields = [
-            'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool',
-            'description',
+            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
         ]
         ]
 
 
     def clean(self):
     def clean(self):
 
 
-        super(PrefixFromCSVForm, self).clean()
+        super(PrefixCSVForm, self).clean()
 
 
         site = self.cleaned_data.get('site')
         site = self.cleaned_data.get('site')
-        vlan_group_name = self.cleaned_data.get('vlan_group_name')
+        vlan_group = self.cleaned_data.get('vlan_group')
         vlan_vid = self.cleaned_data.get('vlan_vid')
         vlan_vid = self.cleaned_data.get('vlan_vid')
-        vlan_group = None
 
 
-        # Validate VLAN group
-        if vlan_group_name:
+        # Validate VLAN
+        if vlan_group and vlan_vid:
             try:
             try:
-                vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
-            except VLANGroup.DoesNotExist:
+                self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid)
+            except VLAN.DoesNotExist:
                 if site:
                 if site:
-                    self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
+                    raise forms.ValidationError("VLAN {} not found in site {} group {}".format(
+                        vlan_vid, site, vlan_group
+                    ))
                 else:
                 else:
-                    self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
-
-        # Validate VLAN
-        if vlan_vid:
+                    raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group))
+        elif vlan_vid:
             try:
             try:
-                self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid)
+                self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid)
             except VLAN.DoesNotExist:
             except VLAN.DoesNotExist:
                 if site:
                 if site:
-                    self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
-                elif vlan_group:
-                    self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name))
-                elif not vlan_group_name:
-                    self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid))
-            except VLAN.MultipleObjectsReturned:
-                self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
-
-    def clean_status(self):
-        status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES}
-        try:
-            return status_choices[self.cleaned_data['status'].lower()]
-        except KeyError:
-            raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
-
-
-class PrefixImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=PrefixFromCSVForm)
+                    raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site))
+                else:
+                    raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid))
 
 
 
 
 class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -513,16 +541,46 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
 
 
-class IPAddressFromCSVForm(forms.ModelForm):
-    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
-                                 error_messages={'invalid_choice': 'VRF not found.'})
-    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
-                                    error_messages={'invalid_choice': 'Tenant not found.'})
-    status = forms.CharField()
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
-                                    error_messages={'invalid_choice': 'Device not found.'})
-    interface_name = forms.CharField(required=False)
-    is_primary = forms.BooleanField(required=False)
+class IPAddressCSVForm(forms.ModelForm):
+    vrf = forms.ModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        to_field_name='rd',
+        help_text='Route distinguisher of the assigned VRF',
+        error_messages={
+            'invalid_choice': 'VRF not found.',
+        }
+    )
+    tenant = forms.ModelChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Name of the assigned tenant',
+        error_messages={
+            'invalid_choice': 'Tenant not found.',
+        }
+    )
+    status = CSVChoiceField(
+        choices=PREFIX_STATUS_CHOICES,
+        help_text='Operational status'
+    )
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of assigned device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    interface_name = forms.CharField(
+        help_text='Name of assigned interface',
+        required=False
+    )
+    is_primary = forms.BooleanField(
+        help_text='Make this the primary IP for the assigned device',
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -530,6 +588,8 @@ class IPAddressFromCSVForm(forms.ModelForm):
 
 
     def clean(self):
     def clean(self):
 
 
+        super(IPAddressCSVForm, self).clean()
+
         device = self.cleaned_data.get('device')
         device = self.cleaned_data.get('device')
         interface_name = self.cleaned_data.get('interface_name')
         interface_name = self.cleaned_data.get('interface_name')
         is_primary = self.cleaned_data.get('is_primary')
         is_primary = self.cleaned_data.get('is_primary')
@@ -537,24 +597,17 @@ class IPAddressFromCSVForm(forms.ModelForm):
         # Validate interface
         # Validate interface
         if device and interface_name:
         if device and interface_name:
             try:
             try:
-                Interface.objects.get(device=device, name=interface_name)
+                self.instance.interface = Interface.objects.get(device=device, name=interface_name)
             except Interface.DoesNotExist:
             except Interface.DoesNotExist:
-                self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device))
+                raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device))
         elif device and not interface_name:
         elif device and not interface_name:
-            self.add_error('interface_name', "Device set ({}) but interface missing".format(device))
+            raise forms.ValidationError("Device set ({}) but interface missing".format(device))
         elif interface_name and not device:
         elif interface_name and not device:
-            self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name))
+            raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name))
 
 
         # Validate is_primary
         # Validate is_primary
         if is_primary and not device:
         if is_primary and not device:
-            self.add_error('is_primary', "No device specified; cannot set as primary IP")
-
-    def clean_status(self):
-        status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES}
-        try:
-            return status_choices[self.cleaned_data['status'].lower()]
-        except KeyError:
-            raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
+            raise forms.ValidationError("No device specified; cannot set as primary IP")
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
@@ -569,11 +622,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
             elif self.instance.address.version == 6:
             elif self.instance.address.version == 6:
                 self.instance.primary_ip6_for = self.cleaned_data['device']
                 self.instance.primary_ip6_for = self.cleaned_data['device']
 
 
-        return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
-
-
-class IPAddressImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=IPAddressFromCSVForm)
+        return super(IPAddressCSVForm, self).save(*args, **kwargs)
 
 
 
 
 class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -673,60 +722,67 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class VLANFromCSVForm(forms.ModelForm):
+class VLANCSVForm(forms.ModelForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
-        queryset=Site.objects.all(), required=False, to_field_name='name',
-        error_messages={'invalid_choice': 'Site not found.'}
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of parent site',
+        error_messages={
+            'invalid_choice': 'Site not found.',
+        }
+    )
+    group_name = forms.CharField(
+        help_text='Name of VLAN group',
+        required=False
     )
     )
-    group_name = forms.CharField(required=False)
     tenant = forms.ModelChoiceField(
     tenant = forms.ModelChoiceField(
-        Tenant.objects.all(), to_field_name='name', required=False,
-        error_messages={'invalid_choice': 'Tenant not found.'}
+        queryset=Tenant.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Name of assigned tenant',
+        error_messages={
+            'invalid_choice': 'Tenant not found.',
+        }
+    )
+    status = CSVChoiceField(
+        choices=VLAN_STATUS_CHOICES,
+        help_text='Operational status'
     )
     )
-    status = forms.CharField()
     role = forms.ModelChoiceField(
     role = forms.ModelChoiceField(
-        queryset=Role.objects.all(), required=False, to_field_name='name',
-        error_messages={'invalid_choice': 'Invalid role.'}
+        queryset=Role.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Functional role',
+        error_messages={
+            'invalid_choice': 'Invalid role.',
+        }
     )
     )
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
         fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
         fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+        help_texts = {
+            'vid': 'Numeric VLAN ID (1-4095)',
+            'name': 'VLAN name',
+        }
 
 
     def clean(self):
     def clean(self):
 
 
-        super(VLANFromCSVForm, self).clean()
+        super(VLANCSVForm, self).clean()
 
 
-        # Validate VLANGroup
+        site = self.cleaned_data.get('site')
         group_name = self.cleaned_data.get('group_name')
         group_name = self.cleaned_data.get('group_name')
+
+        # Validate VLAN group
         if group_name:
         if group_name:
             try:
             try:
-                VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
+                self.instance.group = VLANGroup.objects.get(site=site, name=group_name)
             except VLANGroup.DoesNotExist:
             except VLANGroup.DoesNotExist:
-                self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
-
-    def clean_status(self):
-        status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES}
-        try:
-            return status_choices[self.cleaned_data['status'].lower()]
-        except KeyError:
-            raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
-
-    def save(self, *args, **kwargs):
-
-        vlan = super(VLANFromCSVForm, self).save(commit=False)
-
-        # Assign VLANGroup by site and name
-        if self.cleaned_data['group_name']:
-            vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
-
-        if kwargs.get('commit'):
-            vlan.save()
-        return vlan
-
-
-class VLANImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=VLANFromCSVForm)
+                if site:
+                    raise forms.ValidationError("VLAN group {} not found for site {}".format(group_name, site))
+                else:
+                    raise forms.ValidationError("Global VLAN group {} not found".format(group_name))
 
 
 
 
 class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 1 - 3
netbox/ipam/models.py

@@ -498,9 +498,7 @@ class VLANGroup(models.Model):
         verbose_name_plural = 'VLAN groups'
         verbose_name_plural = 'VLAN groups'
 
 
     def __str__(self):
     def __str__(self):
-        if self.site is None:
-            return self.name
-        return '{} - {}'.format(self.site.name, self.name)
+        return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)

+ 5 - 10
netbox/ipam/views.py

@@ -130,9 +130,8 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
 class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'ipam.add_vrf'
     permission_required = 'ipam.add_vrf'
-    form = forms.VRFImportForm
+    model_form = forms.VRFCSVForm
     table = tables.VRFTable
     table = tables.VRFTable
-    template_name = 'ipam/vrf_import.html'
     default_return_url = 'ipam:vrf_list'
     default_return_url = 'ipam:vrf_list'
 
 
 
 
@@ -341,9 +340,8 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
 class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'ipam.add_aggregate'
     permission_required = 'ipam.add_aggregate'
-    form = forms.AggregateImportForm
+    model_form = forms.AggregateCSVForm
     table = tables.AggregateTable
     table = tables.AggregateTable
-    template_name = 'ipam/aggregate_import.html'
     default_return_url = 'ipam:aggregate_list'
     default_return_url = 'ipam:aggregate_list'
 
 
 
 
@@ -538,9 +536,8 @@ class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'ipam.add_prefix'
     permission_required = 'ipam.add_prefix'
-    form = forms.PrefixImportForm
+    model_form = forms.PrefixCSVForm
     table = tables.PrefixTable
     table = tables.PrefixTable
-    template_name = 'ipam/prefix_import.html'
     default_return_url = 'ipam:prefix_list'
     default_return_url = 'ipam:prefix_list'
 
 
 
 
@@ -640,9 +637,8 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
 
 
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'ipam.add_ipaddress'
     permission_required = 'ipam.add_ipaddress'
-    form = forms.IPAddressImportForm
+    model_form = forms.IPAddressCSVForm
     table = tables.IPAddressTable
     table = tables.IPAddressTable
-    template_name = 'ipam/ipaddress_import.html'
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
     def save_obj(self, obj):
     def save_obj(self, obj):
@@ -748,9 +744,8 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
 class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'ipam.add_vlan'
     permission_required = 'ipam.add_vlan'
-    form = forms.VLANImportForm
+    model_form = forms.VLANCSVForm
     table = tables.VLANTable
     table = tables.VLANTable
-    template_name = 'ipam/vlan_import.html'
     default_return_url = 'ipam:vlan_list'
     default_return_url = 'ipam:vlan_list'
 
 
 
 

+ 25 - 12
netbox/secrets/forms.py

@@ -7,7 +7,7 @@ from django import forms
 from django.db.models import Count
 from django.db.models import Count
 
 
 from dcim.models import Device
 from dcim.models import Device
-from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
+from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
 from .models import Secret, SecretRole, UserKey
 from .models import Secret, SecretRole, UserKey
 
 
 
 
@@ -65,27 +65,40 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
             })
             })
 
 
 
 
-class SecretFromCSVForm(forms.ModelForm):
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
-                                    error_messages={'invalid_choice': 'Device not found.'})
-    role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name',
-                                  error_messages={'invalid_choice': 'Invalid secret role.'})
-    plaintext = forms.CharField()
+class SecretCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Device name or ID',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    role = forms.ModelChoiceField(
+        queryset=SecretRole.objects.all(),
+        to_field_name='name',
+        help_text='Name of assigned role',
+        error_messages={
+            'invalid_choice': 'Invalid secret role.',
+        }
+    )
+    plaintext = forms.CharField(
+        help_text='Plaintext secret data'
+    )
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret
         fields = ['device', 'role', 'name', 'plaintext']
         fields = ['device', 'role', 'name', 'plaintext']
+        help_texts = {
+            'name': 'Name or username',
+        }
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        s = super(SecretFromCSVForm, self).save(*args, **kwargs)
+        s = super(SecretCSVForm, self).save(*args, **kwargs)
         s.plaintext = str(self.cleaned_data['plaintext'])
         s.plaintext = str(self.cleaned_data['plaintext'])
         return s
         return s
 
 
 
 
-class SecretImportForm(BootstrapMixin, BulkImportForm):
-    csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-session-key'}))
-
-
 class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
 class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
     role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
     role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)

+ 1 - 1
netbox/secrets/urls.py

@@ -16,7 +16,7 @@ urlpatterns = [
 
 
     # Secrets
     # Secrets
     url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
     url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
-    url(r'^secrets/import/$', views.secret_import, name='secret_import'),
+    url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'),
     url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
     url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
     url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
     url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
     url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
     url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),

+ 35 - 41
netbox/secrets/views.py

@@ -12,7 +12,9 @@ from django.utils.decorators import method_decorator
 from django.views.generic import View
 from django.views.generic import View
 
 
 from dcim.models import Device
 from dcim.models import Device
-from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
+from utilities.views import (
+    BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+)
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .decorators import userkey_required
 from .decorators import userkey_required
 from .models import SecretRole, Secret, SessionKey
 from .models import SecretRole, Secret, SessionKey
@@ -185,58 +187,50 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     default_return_url = 'secrets:secret_list'
     default_return_url = 'secrets:secret_list'
 
 
 
 
-@permission_required('secrets.add_secret')
-@userkey_required()
-def secret_import(request):
-
-    session_key = request.COOKIES.get('session_key', None)
+class SecretBulkImportView(BulkImportView):
+    permission_required = 'ipam.add_vlan'
+    model_form = forms.SecretCSVForm
+    table = tables.SecretTable
+    default_return_url = 'secrets:secret_list'
 
 
-    if request.method == 'POST':
-        form = forms.SecretImportForm(request.POST)
+    master_key = None
 
 
-        if session_key is None:
-            form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
+    def _save_obj(self, obj_form):
+        """
+        Encrypt each object before saving it to the database.
+        """
+        obj = obj_form.save(commit=False)
+        obj.encrypt(self.master_key)
+        obj.save()
+        return obj
 
 
-        if form.is_valid():
+    def post(self, request):
 
 
-            new_secrets = []
+        # Grab the session key from cookies.
+        session_key = request.COOKIES.get('session_key')
+        if session_key:
 
 
-            session_key = base64.b64decode(session_key)
-            master_key = None
+            # Attempt to derive the master key using the provided session key.
             try:
             try:
                 sk = SessionKey.objects.get(userkey__user=request.user)
                 sk = SessionKey.objects.get(userkey__user=request.user)
-                master_key = sk.get_master_key(session_key)
+                self.master_key = sk.get_master_key(base64.b64decode(session_key))
             except SessionKey.DoesNotExist:
             except SessionKey.DoesNotExist:
-                form.add_error(None, "No session key found for this user.")
+                messages.error(request, "No session key found for this user.")
 
 
-            if master_key is None:
-                form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
+            if self.master_key is not None:
+                return super(SecretBulkImportView, self).post(request)
             else:
             else:
-                try:
-                    with transaction.atomic():
-                        for secret in form.cleaned_data['csv']:
-                            secret.encrypt(master_key)
-                            secret.save()
-                            new_secrets.append(secret)
-
-                    table = tables.SecretTable(new_secrets)
-                    messages.success(request, "Imported {} new secrets.".format(len(new_secrets)))
-
-                    return render(request, 'import_success.html', {
-                        'table': table,
-                        'return_url': 'secrets:secret_list',
-                    })
-
-                except IntegrityError as e:
-                    form.add_error('csv', "Record {}: {}".format(len(new_secrets) + 1, e.__cause__))
+                messages.error(request, "Invalid private key! Unable to encrypt secret data.")
 
 
-    else:
-        form = forms.SecretImportForm()
+        else:
+            messages.error(request, "No session key was provided with the request. Unable to encrypt secret data.")
 
 
-    return render(request, 'secrets/secret_import.html', {
-        'form': form,
-        'return_url': 'secrets:secret_list',
-    })
+        return render(request, self.template_name, {
+            'form': self._import_form(request.POST),
+            'fields': self.model_form().fields,
+            'obj_type': self.model_form._meta.model._meta.verbose_name,
+            'return_url': self.default_return_url,
+        })
 
 
 
 
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):

+ 0 - 55
netbox/templates/circuits/circuit_import.html

@@ -1,55 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Circuit 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>Circuit ID</td>
-                <td>Alphanumeric circuit identifier</td>
-                <td>IC-603122</td>
-            </tr>
-            <tr>
-                <td>Provider</td>
-                <td>Name of circuit provider</td>
-                <td>TeliaSonera</td>
-            </tr>
-            <tr>
-                <td>Type</td>
-                <td>Circuit type</td>
-                <td>Transit</td>
-            </tr>
-            <tr>
-                <td>Tenant</td>
-                <td>Name of tenant (optional)</td>
-                <td>Strickland Propane</td>
-            </tr>
-            <tr>
-                <td>Install Date</td>
-                <td>Date in YYYY-MM-DD format (optional)</td>
-                <td>2016-02-23</td>
-            </tr>
-            <tr>
-                <td>Commit rate</td>
-                <td>Commited rate in Kbps (optional)</td>
-                <td>2000</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Short description (optional)</td>
-                <td>Primary for voice</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
-{% endblock %}

+ 0 - 45
netbox/templates/circuits/provider_import.html

@@ -1,45 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Provider 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>Provider's proper name</td>
-                <td>Level 3</td>
-            </tr>
-            <tr>
-                <td>Slug</td>
-                <td>URL-friendly name</td>
-                <td>level3</td>
-            </tr>
-            <tr>
-                <td>ASN</td>
-                <td>Autonomous system number (optional)</td>
-                <td>3356</td>
-            </tr>
-            <tr>
-                <td>Account</td>
-                <td>Account number (optional)</td>
-                <td>08931544</td>
-            </tr>
-            <tr>
-                <td>Portal URL</td>
-                <td>Customer service portal URL (optional)</td>
-                <td>https://mylevel3.net</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>Level 3,level3,3356,08931544,https://mylevel3.net</pre>
-{% endblock %}

+ 0 - 45
netbox/templates/dcim/console_connections_import.html

@@ -1,45 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Console Connections 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>Console server</td>
-                <td>Device name or {ID}</td>
-                <td>abc1-cs3</td>
-            </tr>
-            <tr>
-                <td>Console server port</td>
-                <td>Full CS port name</td>
-                <td>Port 35</td>
-            </tr>
-            <tr>
-                <td>Device</td>
-                <td>Device name or {ID}</td>
-                <td>abc1-switch7</td>
-            </tr>
-            <tr>
-                <td>Console Port</td>
-                <td>Console port name</td>
-                <td>Console</td>
-            </tr>
-            <tr>
-                <td>Connection Status</td>
-                <td>"planned" or "connected"</td>
-                <td>planned</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>abc1-cs3,Port 35,abc1-switch7,Console,planned</pre>
-{% endblock %}

+ 3 - 101
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' %}
-<div class="row">
-	<div class="col-md-12">
-		<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="{% url return_url %}" class="btn btn-default">Cancel</a>
-                    {% endif %}
-                </div>
-            </div>
-		</form>
-		<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>Device name (optional)</td>
-					<td>rack101_sw1</td>
-				</tr>
-				<tr>
-					<td>Device role</td>
-					<td>Functional role of device</td>
-					<td>ToR Switch</td>
-				</tr>
-				<tr>
-					<td>Tenant</td>
-					<td>Name of tenant (optional)</td>
-					<td>Pied Piper</td>
-				</tr>
-				<tr>
-					<td>Device manufacturer</td>
-					<td>Hardware manufacturer</td>
-					<td>Juniper</td>
-				</tr>
-				<tr>
-					<td>Device model</td>
-					<td>Hardware model</td>
-					<td>EX4300-48T</td>
-				</tr>
-				<tr>
-					<td>Platform</td>
-					<td>Software running on device (optional)</td>
-					<td>Juniper Junos</td>
-				</tr>
-				<tr>
-					<td>Serial number</td>
-					<td>Physical serial number (optional)</td>
-					<td>CAB00577291</td>
-				</tr>
-				<tr>
-					<td>Asset tag</td>
-					<td>Unique alphanumeric tag (optional)</td>
-					<td>ABC123456</td>
-				</tr>
-                <tr>
-                    <td>Status</td>
-                    <td>Current status</td>
-                    <td>Active</td>
-                </tr>
-				<tr>
-					<td>Site</td>
-					<td>Site name</td>
-					<td>Ashburn-VA</td>
-				</tr>
-				<tr>
-					<td>Rack</td>
-					<td>Rack name (optional)</td>
-					<td>R101</td>
-				</tr>
-				<tr>
-					<td>Position (U)</td>
-					<td>Lowest-numbered rack unit occupied by the device (optional)</td>
-					<td>21</td>
-				</tr>
-				<tr>
-					<td>Face</td>
-					<td>Rack face; front or rear (required if position is set)</td>
-					<td>Rear</td>
-				</tr>
-			</tbody>
-		</table>
-		<h4>Example</h4>
-		<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Active,Ashburn-VA,R101,21,Rear</pre>
-	</div>
-</div>
+{% block tabs %}
+    {% include 'dcim/inc/device_import_header.html' %}
 {% endblock %}
 {% endblock %}

+ 3 - 91
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' %}
-<div class="row">
-	<div class="col-md-12">
-		<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="{% url return_url %}" class="btn btn-default">Cancel</a>
-                    {% endif %}
-                </div>
-            </div>
-		</form>
-		<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>Device name (optional)</td>
-					<td>Blade12</td>
-				</tr>
-				<tr>
-					<td>Device role</td>
-					<td>Functional role of device</td>
-					<td>Blade Server</td>
-				</tr>
-				<tr>
-					<td>Tenant</td>
-					<td>Name of tenant (optional)</td>
-					<td>Pied Piper</td>
-				</tr>
-				<tr>
-					<td>Device manufacturer</td>
-					<td>Hardware manufacturer</td>
-					<td>Dell</td>
-				</tr>
-				<tr>
-					<td>Device model</td>
-					<td>Hardware model</td>
-					<td>BS2000T</td>
-				</tr>
-				<tr>
-					<td>Platform</td>
-					<td>Software running on device (optional)</td>
-					<td>Linux</td>
-				</tr>
-				<tr>
-					<td>Serial number</td>
-					<td>Physical serial number (optional)</td>
-					<td>CAB00577291</td>
-				</tr>
-				<tr>
-					<td>Asset tag</td>
-					<td>Unique alphanumeric tag (optional)</td>
-					<td>ABC123456</td>
-				</tr>
-                <tr>
-                    <td>Status</td>
-                    <td>Current status</td>
-                    <td>Active</td>
-                </tr>
-				<tr>
-					<td>Parent device</td>
-					<td>Parent device</td>
-					<td>Server101</td>
-				</tr>
-				<tr>
-					<td>Device bay</td>
-					<td>Device bay name</td>
-					<td>Slot 4</td>
-				</tr>
-			</tbody>
-		</table>
-		<h4>Example</h4>
-		<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Active,Server101,Slot4</pre>
-	</div>
-</div>
+{% block tabs %}
+    {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
 {% endblock %}
 {% endblock %}

+ 0 - 1
netbox/templates/dcim/inc/device_import_header.html

@@ -1,4 +1,3 @@
-<h1>Device Import</h1>
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
     <li role="presentation"{% if not active_tab %} class="active"{% endif %}><a href="{% url 'dcim:device_import' %}">Racked Devices</a></li>
     <li role="presentation"{% if not active_tab %} class="active"{% endif %}><a href="{% url 'dcim:device_import' %}">Racked Devices</a></li>
     <li role="presentation"{% if active_tab == 'child_import' %} class="active"{% endif %}><a href="{% url 'dcim:device_import_child' %}">Child Devices</a></li>
     <li role="presentation"{% if active_tab == 'child_import' %} class="active"{% endif %}><a href="{% url 'dcim:device_import_child' %}">Child Devices</a></li>

+ 0 - 45
netbox/templates/dcim/interface_connections_import.html

@@ -1,45 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Interface Connections 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>Device A</td>
-                <td>Device name or {ID}</td>
-                <td>abc1-core1</td>
-            </tr>
-            <tr>
-                <td>Interface A</td>
-                <td>Interface name</td>
-                <td>xe-0/0/6</td>
-            </tr>
-            <tr>
-                <td>Device B</td>
-                <td>Device name or {ID}</td>
-                <td>abc1-switch7</td>
-            </tr>
-            <tr>
-                <td>Interface B</td>
-                <td>Interface name</td>
-                <td>xe-0/0/0</td>
-            </tr>
-            <tr>
-                <td>Connection Status</td>
-                <td>"planned" or "connected"</td>
-                <td>planned</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned</pre>
-{% endblock %}

+ 0 - 45
netbox/templates/dcim/power_connections_import.html

@@ -1,45 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Power Connections 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>PDU</td>
-                <td>Device name or {ID}</td>
-                <td>abc1-pdu1</td>
-            </tr>
-            <tr>
-                <td>Power Outlet</td>
-                <td>Power outlet name</td>
-                <td>AC4</td>
-            </tr>
-            <tr>
-                <td>Device</td>
-                <td>Device name or {ID}</td>
-                <td>abc1-switch7</td>
-            </tr>
-            <tr>
-                <td>Power Port</td>
-                <td>Power port name</td>
-                <td>PSU0</td>
-            </tr>
-            <tr>
-                <td>Connection Status</td>
-                <td>"planned" or "connected"</td>
-                <td>connected</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>abc1-pdu1,AC4,abc1-switch7,PSU0,connected</pre>
-{% endblock %}

+ 0 - 70
netbox/templates/dcim/rack_import.html

@@ -1,70 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Rack 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>Site</td>
-                <td>Name of the assigned site</td>
-                <td>DC-4</td>
-            </tr>
-            <tr>
-                <td>Group</td>
-                <td>Rack group name (optional)</td>
-                <td>Cage 1400</td>
-            </tr>
-            <tr>
-                <td>Name</td>
-                <td>Internal rack name</td>
-                <td>R101</td>
-            </tr>
-            <tr>
-                <td>Facility ID</td>
-                <td>Rack ID assigned by the facility (optional)</td>
-                <td>J12.100</td>
-            </tr>
-            <tr>
-                <td>Tenant</td>
-                <td>Name of tenant (optional)</td>
-                <td>Pied Piper</td>
-            </tr>
-            <tr>
-                <td>Role</td>
-                <td>Functional role (optional)</td>
-                <td>Compute</td>
-            </tr>
-            <tr>
-                <td>Type</td>
-                <td>Rack type (optional)</td>
-                <td>4-post cabinet</td>
-            </tr>
-            <tr>
-                <td>Width</td>
-                <td>Rail-to-rail width (19 or 23 inches)</td>
-                <td>19</td>
-            </tr>
-            <tr>
-                <td>Height</td>
-                <td>Height in rack units</td>
-                <td>42</td>
-            </tr>
-            <tr>
-                <td>Descending units</td>
-                <td>Units are numbered top-to-bottom</td>
-                <td>False</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
-{% endblock %}

+ 0 - 81
netbox/templates/dcim/site_import.html

@@ -1,81 +0,0 @@
-{% extends '_base.html' %}
-{% load form_helpers %}
-
-{% block title %}Site Import{% endblock %}
-
-{% block content %}
-<h1>Site Import</h1>
-<div class="row">
-	<div class="col-md-6">
-		<form action="." method="post" class="form">
-		    {% csrf_token %}
-		    {% render_form form %}
-		    <div class="form-group">
-		        <button type="submit" class="btn btn-primary">Submit</button>
-                <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
-		    </div>
-		</form>
-	</div>
-	<div class="col-md-6">
-		<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>Site's proper name</td>
-					<td>ASH-4 South</td>
-				</tr>
-				<tr>
-					<td>Slug</td>
-					<td>URL-friendly name</td>
-					<td>ash4-south</td>
-				</tr>
-				<tr>
-					<td>Region</td>
-					<td>Name of region (optional)</td>
-					<td>North America</td>
-				</tr>
-				<tr>
-					<td>Tenant</td>
-					<td>Name of tenant (optional)</td>
-					<td>Pied Piper</td>
-				</tr>
-				<tr>
-					<td>Facility</td>
-					<td>Name of the hosting facility (optional)</td>
-					<td>Equinix DC6</td>
-				</tr>
-				<tr>
-					<td>ASN</td>
-					<td>Autonomous system number (optional)</td>
-					<td>65000</td>
-				</tr>
-				<tr>
-					<td>Contact Name</td>
-					<td>Name of administrative contact (optional)</td>
-					<td>Hank Hill</td>
-				</tr>
-				<tr>
-					<td>Contact Phone</td>
-					<td>Phone number (optional)</td>
-					<td>+1-214-555-1234</td>
-				</tr>
-				<tr>
-					<td>Contact E-mail</td>
-					<td>E-mail address (optional)</td>
-					<td>hhill@example.com</td>
-				</tr>
-			</tbody>
-		</table>
-		<h4>Example</h4>
-		<pre>ASH-4 South,ash4-south,North America,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com</pre>
-	</div>
-</div>
-{% endblock %}

+ 0 - 40
netbox/templates/ipam/aggregate_import.html

@@ -1,40 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Aggregate 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>Prefix</td>
-                <td>IPv4 or IPv6 network</td>
-                <td>172.16.0.0/12</td>
-            </tr>
-            <tr>
-                <td>RIR</td>
-                <td>Name of RIR</td>
-                <td>RFC 1918</td>
-            </tr>
-            <tr>
-                <td>Date Added</td>
-                <td>Date in YYYY-MM-DD format (optional)</td>
-                <td>2016-02-23</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Short description (optional)</td>
-                <td>Private IPv4 space</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space</pre>
-{% endblock %}

+ 0 - 60
netbox/templates/ipam/ipaddress_import.html

@@ -1,60 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}IP Address 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>Address</td>
-                <td>IPv4 or IPv6 address</td>
-                <td>192.0.2.42/24</td>
-            </tr>
-            <tr>
-                <td>VRF</td>
-                <td>VRF route distinguisher (optional)</td>
-                <td>65000:123</td>
-            </tr>
-            <tr>
-                <td>Tenant</td>
-                <td>Name of tenant (optional)</td>
-                <td>ABC01</td>
-            </tr>
-            <tr>
-                <td>Status</td>
-                <td>Current status</td>
-                <td>Active</td>
-            </tr>
-            <tr>
-                <td>Device</td>
-                <td>Device name (optional)</td>
-                <td>switch12</td>
-            </tr>
-            <tr>
-                <td>Interface</td>
-                <td>Interface name (optional)</td>
-                <td>ge-0/0/31</td>
-            </tr>
-            <tr>
-                <td>Is Primary</td>
-                <td>If "true", IP will be primary for device (optional)</td>
-                <td>True</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Short description (optional)</td>
-                <td>Management IP</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>192.0.2.42/24,65000:123,ABC01,Active,switch12,ge-0/0/31,True,Management IP</pre>
-{% endblock %}

+ 0 - 70
netbox/templates/ipam/prefix_import.html

@@ -1,70 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}Prefix 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>Prefix</td>
-                <td>IPv4 or IPv6 network</td>
-                <td>192.168.42.0/24</td>
-            </tr>
-            <tr>
-                <td>VRF</td>
-                <td>VRF route distinguisher (optional)</td>
-                <td>65000:123</td>
-            </tr>
-            <tr>
-                <td>Tenant</td>
-                <td>Name of tenant (optional)</td>
-                <td>ABC01</td>
-            </tr>
-            <tr>
-                <td>Site</td>
-                <td>Name of assigned site (optional)</td>
-                <td>HQ</td>
-            </tr>
-            <tr>
-                <td>VLAN Group</td>
-                <td>Name of group for VLAN selection (optional)</td>
-                <td>Customers</td>
-            </tr>
-            <tr>
-                <td>VLAN ID</td>
-                <td>Numeric VLAN ID (optional)</td>
-                <td>801</td>
-            </tr>
-            <tr>
-                <td>Status</td>
-                <td>Current status</td>
-                <td>Active</td>
-            </tr>
-            <tr>
-                <td>Role</td>
-                <td>Functional role (optional)</td>
-                <td>Customer</td>
-            </tr>
-            <tr>
-                <td>Is a pool</td>
-                <td>True if all IPs are considered usable</td>
-                <td>False</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Short description (optional)</td>
-                <td>7th floor WiFi</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,False,7th floor WiFi</pre>
-{% endblock %}

+ 0 - 60
netbox/templates/ipam/vlan_import.html

@@ -1,60 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}VLAN 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>Site</td>
-                <td>Name of assigned site (optional)</td>
-                <td>LAS2</td>
-            </tr>
-            <tr>
-                <td>Group</td>
-                <td>Name of VLAN group (optional)</td>
-                <td>Backend Network</td>
-            </tr>
-            <tr>
-                <td>ID</td>
-                <td>Configured VLAN ID</td>
-                <td>1400</td>
-            </tr>
-            <tr>
-                <td>Name</td>
-                <td>Configured VLAN name</td>
-                <td>Cameras</td>
-            </tr>
-            <tr>
-                <td>Tenant</td>
-                <td>Name of tenant (optional)</td>
-                <td>Internal</td>
-            </tr>
-            <tr>
-                <td>Status</td>
-                <td>Current status</td>
-                <td>Active</td>
-            </tr>
-            <tr>
-                <td>Role</td>
-                <td>Functional role (optional)</td>
-                <td>Security</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Short description (optional)</td>
-                <td>Security team only</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
-{% endblock %}

+ 0 - 45
netbox/templates/ipam/vrf_import.html

@@ -1,45 +0,0 @@
-{% extends 'utilities/obj_import.html' %}
-
-{% block title %}VRF 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>Name of VRF</td>
-                <td>Customer_ABC</td>
-            </tr>
-            <tr>
-                <td>RD</td>
-                <td>Route distinguisher</td>
-                <td>65000:123456</td>
-            </tr>
-            <tr>
-                <td>Tenant</td>
-                <td>Name of tenant (optional)</td>
-                <td>ABC01</td>
-            </tr>
-            <tr>
-                <td>Enforce uniqueness</td>
-                <td>Prevent duplicate prefixes/IP addresses</td>
-                <td>True</td>
-            </tr>
-            <tr>
-                <td>Description</td>
-                <td>Short description (optional)</td>
-                <td>Native VRF for customer ABC</td>
-            </tr>
-        </tbody>
-    </table>
-    <h4>Example</h4>
-    <pre>Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC</pre>
-{% endblock %}

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

@@ -1,40 +0,0 @@
-{% 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 %}

+ 31 - 4
netbox/templates/utilities/obj_import.html

@@ -1,10 +1,12 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
-<h1>{% block title %}{% endblock %}</h1>
+<h1>{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}</h1>
+{% block tabs %}{% endblock %}
 <div class="row">
 <div class="row">
-	<div class="col-md-6">
+	<div class="col-md-7">
         {% if form.non_field_errors %}
         {% if form.non_field_errors %}
             <div class="panel panel-danger">
             <div class="panel panel-danger">
                 <div class="panel-heading"><strong>Errors</strong></div>
                 <div class="panel-heading"><strong>Errors</strong></div>
@@ -26,8 +28,33 @@
             </div>
             </div>
 		</form>
 		</form>
 	</div>
 	</div>
-	<div class="col-md-6">
-        {% block instructions %}{% endblock %}
+	<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.choices|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>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 17 - 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,25 @@ 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):
+    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.'
+        }
+    )
 
 
     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']
+        help_texts = {
+            'name': 'Tenant name',
+            'comments': 'Free-form comments'
+        }
 
 
 
 
 class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 1 - 2
netbox/tenancy/views.py

@@ -97,9 +97,8 @@ class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
 class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
     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'
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'
 
 
 
 

+ 51 - 42
netbox/utilities/forms.py

@@ -217,45 +217,79 @@ class Livesearch(forms.TextInput):
 
 
 class CSVDataField(forms.CharField):
 class CSVDataField(forms.CharField):
     """
     """
-    A field for comma-separated values (CSV). Values containing commas should be encased within double quotes. Example:
-        '"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
+    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.
     """
     """
-    csv_form = None
     widget = forms.Textarea
     widget = forms.Textarea
 
 
-    def __init__(self, csv_form, *args, **kwargs):
-        self.csv_form = csv_form
-        self.columns = self.csv_form().fields.keys()
+    def __init__(self, fields, required_fields=[], *args, **kwargs):
+
+        self.fields = fields
+        self.required_fields = required_fields
+
         super(CSVDataField, self).__init__(*args, **kwargs)
         super(CSVDataField, self).__init__(*args, **kwargs)
+
         self.strip = False
         self.strip = False
         if not self.label:
         if not self.label:
             self.label = 'CSV Data'
             self.label = 'CSV Data'
+        if not self.initial:
+            self.initial = ','.join(required_fields) + '\n'
         if not self.help_text:
         if not self.help_text:
-            self.help_text = 'Enter one line per record in CSV format.'
+            self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
+                             'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
+                             'in double quotes.'
 
 
     def to_python(self, value):
     def to_python(self, value):
-        """
-        Return a list of dictionaries, each representing an individual record
-        """
+
         # Python 2's csv module has problems with Unicode
         # Python 2's csv module has problems with Unicode
         if not isinstance(value, str):
         if not isinstance(value, str):
             value = value.encode('utf-8')
             value = value.encode('utf-8')
+
         records = []
         records = []
         reader = csv.reader(value.splitlines())
         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):
         for i, row in enumerate(reader, start=1):
             if row:
             if row:
-                if len(row) < len(self.columns):
-                    raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})"
-                                                .format(i, len(row), len(self.columns)))
-                elif len(row) > len(self.columns):
-                    raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})"
-                                                .format(i, len(row), len(self.columns)))
+                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]
                 row = [col.strip() for col in row]
-                record = dict(zip(self.columns, row))
+                record = dict(zip(headers, row))
                 records.append(record)
                 records.append(record)
+
         return records
         return records
 
 
 
 
+class CSVChoiceField(forms.ChoiceField):
+    """
+    Invert the provided set of choices to take the human-friendly label as input, and return the database value.
+    """
+
+    def __init__(self, choices, *args, **kwargs):
+        super(CSVChoiceField, self).__init__(choices, *args, **kwargs)
+        self.choices = [(label, label) for value, label in choices]
+        self.choice_values = {label: value for value, label in choices}
+
+    def clean(self, value):
+        value = super(CSVChoiceField, self).clean(value)
+        if not value:
+            return None
+        if value not in self.choice_values:
+            raise forms.ValidationError("Invalid choice: {}".format(value))
+        return self.choice_values[value]
+
+
 class ExpandableNameField(forms.CharField):
 class ExpandableNameField(forms.CharField):
     """
     """
     A field which allows for numeric range expansion
     A field which allows for numeric range expansion
@@ -483,28 +517,3 @@ class BulkEditForm(forms.Form):
             self.nullable_fields = [field for field in self.Meta.nullable_fields]
             self.nullable_fields = [field for field in self.Meta.nullable_fields]
         else:
         else:
             self.nullable_fields = []
             self.nullable_fields = []
-
-
-class BulkImportForm(forms.Form):
-
-    def clean(self):
-        records = self.cleaned_data.get('csv')
-        if not records:
-            return
-
-        obj_list = []
-
-        for i, record in enumerate(records, start=1):
-            obj_form = self.fields['csv'].csv_form(data=record)
-            if obj_form.is_valid():
-                obj = obj_form.save(commit=False)
-                obj_list.append(obj)
-            else:
-                for field, errors in obj_form.errors.items():
-                    for e in errors:
-                        if field == '__all__':
-                            self.add_error('csv', "Record {}: {}".format(i, e))
-                        else:
-                            self.add_error('csv', "Record {} ({}): {}".format(i, field, e))
-
-        self.cleaned_data['csv'] = obj_list

+ 4 - 2
netbox/utilities/templatetags/form_helpers.py

@@ -40,7 +40,9 @@ def widget_type(field):
     """
     """
     Return the widget type
     Return the widget type
     """
     """
-    try:
+    if hasattr(field, 'widget'):
+        return field.widget.__class__.__name__.lower()
+    elif hasattr(field, 'field'):
         return field.field.widget.__class__.__name__.lower()
         return field.field.widget.__class__.__name__.lower()
-    except AttributeError:
+    else:
         return None
         return None

+ 18 - 0
netbox/utilities/templatetags/helpers.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from markdown import markdown
 from markdown import markdown
 
 
 from django import template
 from django import template
@@ -60,6 +62,22 @@ def bettertitle(value):
     return ' '.join([w[0].upper() + w[1:] for w in value.split()])
     return ' '.join([w[0].upper() + w[1:] for w in value.split()])
 
 
 
 
+@register.filter()
+def example_choices(value, arg=3):
+    """
+    Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms).
+    """
+    choices = []
+    for id, label in value:
+        if len(choices) == arg:
+            choices.append('etc.')
+            break
+        if not id:
+            continue
+        choices.append(label)
+    return ', '.join(choices) or 'None'
+
+
 #
 #
 # Tags
 # Tags
 #
 #

+ 50 - 19
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, CSVDataField
 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
@@ -371,56 +373,85 @@ class BulkImportView(View):
     """
     """
     Import objects in bulk (CSV format).
     Import objects in bulk (CSV format).
 
 
-    form: Form class
+    model_form: The form used to create each imported object
     table: The django-tables2 Table used to render the list of imported objects
     table: The django-tables2 Table used to render the list of imported objects
     template_name: The name of the template
     template_name: The name of the template
     default_return_url: The name of the URL to use for the cancel button
     default_return_url: The name of the URL to use for the cancel button
     """
     """
-    form = None
+    model_form = None
     table = None
     table = None
-    template_name = None
     default_return_url = None
     default_return_url = None
+    template_name = 'utilities/obj_import.html'
+
+    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 = CSVDataField(fields=fields, required_fields=required_fields)
+
+        return ImportForm(*args, **kwargs)
+
+    def _save_obj(self, obj_form):
+        """
+        Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data).
+        """
+        return obj_form.save()
 
 
     def get(self, request):
     def get(self, request):
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'form': self.form(),
+            '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,
             'return_url': self.default_return_url,
         })
         })
 
 
     def post(self, request):
     def post(self, request):
 
 
-        form = self.form(request.POST)
+        new_objs = []
+        form = self._import_form(request.POST)
+
         if form.is_valid():
         if form.is_valid():
-            new_objs = []
+
             try:
             try:
+
+                # Iterate through CSV data and bind each row to a new model form instance.
                 with transaction.atomic():
                 with transaction.atomic():
-                    for obj in form.cleaned_data['csv']:
-                        self.save_obj(obj)
-                        new_objs.append(obj)
+                    for row, data in enumerate(form.cleaned_data['csv'], start=1):
+                        obj_form = self.model_form(data)
+                        if obj_form.is_valid():
+                            obj = self._save_obj(obj_form)
+                            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)
                 obj_table = self.table(new_objs)
+
                 if new_objs:
                 if new_objs:
                     msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
                     msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
                     messages.success(request, msg)
                     messages.success(request, msg)
                     UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), 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,
-                })
+                    return render(request, "import_success.html", {
+                        'table': obj_table,
+                        'return_url': self.default_return_url,
+                    })
 
 
-            except IntegrityError as e:
-                form.add_error('csv', "Record {}: {}".format(len(new_objs) + 1, e.__cause__))
+            except ValidationError:
+                pass
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
+            'fields': self.model_form().fields,
+            'obj_type': self.model_form._meta.model._meta.verbose_name,
             'return_url': self.default_return_url,
             'return_url': self.default_return_url,
         })
         })
 
 
-    def save_obj(self, obj):
-        obj.save()
-
 
 
 class BulkEditView(View):
 class BulkEditView(View):
     """
     """