Просмотр исходного кода

Merge pull request #4564 from netbox-community/3147-csv-import-fields

Closes #3147: Allow dynamic access to related objects during CSV import
Jeremy Stretch 5 лет назад
Родитель
Сommit
9312dea2b2

+ 10 - 25
netbox/circuits/forms.py

@@ -8,9 +8,9 @@ from extras.forms import (
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
-    DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
-    StaticSelect2Multiple, TagFilterField,
+    APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField,
+    CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField,
+    StaticSelect2, StaticSelect2Multiple, TagFilterField,
 )
 from .choices import CircuitStatusChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -55,12 +55,6 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
     class Meta:
         model = Provider
         fields = Provider.csv_headers
-        help_texts = {
-            'name': 'Provider name',
-            'asn': '32-bit autonomous system number',
-            'portal_url': 'Portal URL',
-            'comments': 'Free-form comments',
-        }
 
 
 class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -148,7 +142,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class CircuitTypeCSVForm(forms.ModelForm):
+class CircuitTypeCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -192,35 +186,26 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class CircuitCSVForm(CustomFieldModelCSVForm):
-    provider = forms.ModelChoiceField(
+    provider = CSVModelChoiceField(
         queryset=Provider.objects.all(),
         to_field_name='name',
-        help_text='Name of parent provider',
-        error_messages={
-            'invalid_choice': 'Provider not found.'
-        }
+        help_text='Assigned provider'
     )
-    type = forms.ModelChoiceField(
+    type = CSVModelChoiceField(
         queryset=CircuitType.objects.all(),
         to_field_name='name',
-        help_text='Type of circuit',
-        error_messages={
-            'invalid_choice': 'Invalid circuit type.'
-        }
+        help_text='Type of circuit'
     )
     status = CSVChoiceField(
         choices=CircuitStatusChoices,
         required=False,
         help_text='Operational status'
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.'
-        }
+        help_text='Assigned tenant'
     )
 
     class Meta:

+ 3 - 2
netbox/circuits/models.py

@@ -38,7 +38,8 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
     asn = ASNField(
         blank=True,
         null=True,
-        verbose_name='ASN'
+        verbose_name='ASN',
+        help_text='32-bit autonomous system number'
     )
     account = models.CharField(
         max_length=30,
@@ -47,7 +48,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
     )
     portal_url = models.URLField(
         blank=True,
-        verbose_name='Portal'
+        verbose_name='Portal URL'
     )
     noc_contact = models.TextField(
         blank=True,

Разница между файлами не показана из-за своего большого размера
+ 202 - 373
netbox/dcim/forms.py


+ 25 - 15
netbox/dcim/models/__init__.py

@@ -180,12 +180,14 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     )
     facility = models.CharField(
         max_length=50,
-        blank=True
+        blank=True,
+        help_text='Local facility ID or description'
     )
     asn = ASNField(
         blank=True,
         null=True,
-        verbose_name='ASN'
+        verbose_name='ASN',
+        help_text='32-bit autonomous system number'
     )
     time_zone = TimeZoneField(
         blank=True
@@ -206,13 +208,15 @@ class Site(ChangeLoggedModel, CustomFieldModel):
         max_digits=8,
         decimal_places=6,
         blank=True,
-        null=True
+        null=True,
+        help_text='GPS coordinate (latitude)'
     )
     longitude = models.DecimalField(
         max_digits=9,
         decimal_places=6,
         blank=True,
-        null=True
+        null=True,
+        help_text='GPS coordinate (longitude)'
     )
     contact_name = models.CharField(
         max_length=50,
@@ -419,7 +423,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         max_length=50,
         blank=True,
         null=True,
-        verbose_name='Facility ID'
+        verbose_name='Facility ID',
+        help_text='Locally-assigned identifier'
     )
     site = models.ForeignKey(
         to='dcim.Site',
@@ -431,7 +436,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         on_delete=models.SET_NULL,
         related_name='racks',
         blank=True,
-        null=True
+        null=True,
+        help_text='Assigned group'
     )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
@@ -450,7 +456,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         on_delete=models.PROTECT,
         related_name='racks',
         blank=True,
-        null=True
+        null=True,
+        help_text='Functional role'
     )
     serial = models.CharField(
         max_length=50,
@@ -480,7 +487,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     u_height = models.PositiveSmallIntegerField(
         default=RACK_U_HEIGHT_DEFAULT,
         verbose_name='Height (U)',
-        validators=[MinValueValidator(1), MaxValueValidator(100)]
+        validators=[MinValueValidator(1), MaxValueValidator(100)],
+        help_text='Height in rack units'
     )
     desc_units = models.BooleanField(
         default=False,
@@ -489,11 +497,13 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     )
     outer_width = models.PositiveSmallIntegerField(
         blank=True,
-        null=True
+        null=True,
+        help_text='Outer dimension of rack (width)'
     )
     outer_depth = models.PositiveSmallIntegerField(
         blank=True,
-        null=True
+        null=True,
+        help_text='Outer dimension of rack (depth)'
     )
     outer_unit = models.CharField(
         max_length=50,
@@ -514,7 +524,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
-        'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
+        'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
         'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
     ]
     clone_fields = [
@@ -821,7 +831,7 @@ class RackReservation(ChangeLoggedModel):
 
     def clean(self):
 
-        if self.units:
+        if hasattr(self, 'rack') and self.units:
 
             # Validate that all specified units exist in the Rack.
             invalid_units = [u for u in self.units if u not in self.rack.units]
@@ -1415,7 +1425,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
-        'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
+        'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
         'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
     ]
     clone_fields = [
@@ -1798,7 +1808,7 @@ class PowerPanel(ChangeLoggedModel):
         max_length=50
     )
 
-    csv_headers = ['site', 'rack_group_name', 'name']
+    csv_headers = ['site', 'rack_group', 'name']
 
     class Meta:
         ordering = ['site', 'name']
@@ -1905,7 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
-        'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+        'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
         'amperage', 'max_utilization', 'comments',
     ]
     clone_fields = [

+ 12 - 7
netbox/dcim/models/device_components.py

@@ -239,7 +239,8 @@ class ConsolePort(CableTermination, ComponentModel):
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     connected_endpoint = models.OneToOneField(
         to='dcim.ConsoleServerPort',
@@ -300,7 +301,8 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
@@ -354,7 +356,8 @@ class PowerPort(CableTermination, ComponentModel):
     type = models.CharField(
         max_length=50,
         choices=PowerPortTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     maximum_draw = models.PositiveSmallIntegerField(
         blank=True,
@@ -516,7 +519,8 @@ class PowerOutlet(CableTermination, ComponentModel):
     type = models.CharField(
         max_length=50,
         choices=PowerOutletTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     power_port = models.ForeignKey(
         to='dcim.PowerPort',
@@ -653,7 +657,7 @@ class Interface(CableTermination, ComponentModel):
     mode = models.CharField(
         max_length=50,
         choices=InterfaceModeChoices,
-        blank=True,
+        blank=True
     )
     untagged_vlan = models.ForeignKey(
         to='ipam.VLAN',
@@ -1083,7 +1087,8 @@ class InventoryItem(ComponentModel):
     part_id = models.CharField(
         max_length=50,
         verbose_name='Part ID',
-        blank=True
+        blank=True,
+        help_text='Manufacturer-assigned part identifier'
     )
     serial = models.CharField(
         max_length=50,
@@ -1100,7 +1105,7 @@ class InventoryItem(ComponentModel):
     )
     discovered = models.BooleanField(
         default=False,
-        verbose_name='Discovered'
+        help_text='This item was automatically discovered'
     )
 
     tags = TaggableManager(through=TaggedItem)

+ 22 - 16
netbox/dcim/tests/test_views.py

@@ -184,7 +184,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         site = Site.objects.create(name='Site 1', slug='site-1')
 
-        rack = Rack(name='Rack 1', site=site)
+        rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site)
+        rack_group.save()
+
+        rack = Rack(name='Rack 1', site=site, group=rack_group)
         rack.save()
 
         RackReservation.objects.bulk_create([
@@ -202,10 +205,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            'site,rack_name,units,description',
-            'Site 1,Rack 1,"10,11,12",Reservation 1',
-            'Site 1,Rack 1,"13,14,15",Reservation 2',
-            'Site 1,Rack 1,"16,17,18",Reservation 3',
+            'site,rack_group,rack,units,description',
+            'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1',
+            'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2',
+            'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3',
         )
 
         cls.bulk_edit_data = {
@@ -268,10 +271,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "site,name,width,u_height",
-            "Site 1,Rack 4,19,42",
-            "Site 1,Rack 5,19,42",
-            "Site 1,Rack 6,19,42",
+            "site,group,name,width,u_height",
+            "Site 1,,Rack 4,19,42",
+            "Site 1,Rack Group 1,Rack 5,19,42",
+            "Site 2,Rack Group 2,Rack 6,19,42",
         )
 
         cls.bulk_edit_data = {
@@ -890,8 +893,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         Site.objects.bulk_create(sites)
 
+        rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1')
+        rack_group.save()
+
         racks = (
-            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 1', site=sites[0], group=rack_group),
             Rack(name='Rack 2', site=sites[1]),
         )
         Rack.objects.bulk_create(racks)
@@ -947,10 +953,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "device_role,manufacturer,model_name,status,site,name",
-            "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 4",
-            "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 5",
-            "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 6",
+            "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
+            "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 4,Site 1,Rack Group 1,Rack 1,10,Front",
+            "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 5,Site 1,Rack Group 1,Rack 1,20,Front",
+            "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front",
         )
 
         cls.bulk_edit_data = {
@@ -1586,7 +1592,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "site,rack_group_name,name",
+            "site,rack_group,name",
             "Site 1,Rack Group 1,Power Panel 4",
             "Site 1,Rack Group 1,Power Panel 5",
             "Site 1,Rack Group 1,Power Panel 6",
@@ -1645,7 +1651,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "site,panel_name,name,voltage,amperage,max_utilization",
+            "site,power_panel,name,voltage,amperage,max_utilization",
             "Site 1,Power Panel 1,Power Feed 4,120,20,80",
             "Site 1,Power Panel 1,Power Feed 5,120,20,80",
             "Site 1,Power Panel 1,Power Feed 6,120,20,80",

+ 2 - 2
netbox/extras/forms.py

@@ -8,7 +8,7 @@ from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
+    ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
     StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import Cluster, ClusterGroup
@@ -89,7 +89,7 @@ class CustomFieldModelForm(forms.ModelForm):
         return obj
 
 
-class CustomFieldModelCSVForm(CustomFieldModelForm):
+class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
 
     def _append_customfield_fields(self):
 

+ 94 - 184
netbox/ipam/forms.py

@@ -1,5 +1,4 @@
 from django import forms
-from django.core.exceptions import MultipleObjectsReturned
 from django.core.validators import MaxValueValidator, MinValueValidator
 from taggit.forms import TagField
 
@@ -11,16 +10,15 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
-    DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
-    FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    CSVModelChoiceField, CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import VirtualMachine
-from .constants import *
 from .choices import *
+from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
-
 PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
     (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
 ])
@@ -53,22 +51,16 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class VRFCSVForm(CustomFieldModelCSVForm):
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
 
     class Meta:
         model = VRF
         fields = VRF.csv_headers
-        help_texts = {
-            'name': 'VRF name',
-        }
 
 
 class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -120,7 +112,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class RIRCSVForm(forms.ModelForm):
+class RIRCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -168,13 +160,10 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
 
 
 class AggregateCSVForm(CustomFieldModelCSVForm):
-    rir = forms.ModelChoiceField(
+    rir = CSVModelChoiceField(
         queryset=RIR.objects.all(),
         to_field_name='name',
-        help_text='Name of parent RIR',
-        error_messages={
-            'invalid_choice': 'RIR not found.',
-        }
+        help_text='Assigned RIR'
     )
 
     class Meta:
@@ -247,15 +236,12 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class RoleCSVForm(forms.ModelForm):
+class RoleCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
         model = Role
         fields = Role.csv_headers
-        help_texts = {
-            'name': 'Role name',
-        }
 
 
 #
@@ -333,92 +319,62 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class PrefixCSVForm(CustomFieldModelCSVForm):
-    vrf = FlexibleModelChoiceField(
+    vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of parent VRF (or {ID})',
-        error_messages={
-            'invalid_choice': 'VRF not found.',
-        }
+        help_text='Assigned VRF'
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
-    site = forms.ModelChoiceField(
+    site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of parent site',
-        error_messages={
-            'invalid_choice': 'Site not found.',
-        }
+        help_text='Assigned site'
     )
-    vlan_group = forms.CharField(
-        help_text='Group name of assigned VLAN',
-        required=False
+    vlan_group = CSVModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text="VLAN's group (if any)"
     )
-    vlan_vid = forms.IntegerField(
-        help_text='Numeric ID of assigned VLAN',
-        required=False
+    vlan = CSVModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text="Assigned VLAN"
     )
     status = CSVChoiceField(
         choices=PrefixStatusChoices,
         help_text='Operational status'
     )
-    role = forms.ModelChoiceField(
+    role = CSVModelChoiceField(
         queryset=Role.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Functional role',
-        error_messages={
-            'invalid_choice': 'Invalid role.',
-        }
+        help_text='Functional role'
     )
 
     class Meta:
         model = Prefix
         fields = Prefix.csv_headers
 
-    def clean(self):
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
 
-        super().clean()
+        if data:
 
-        site = self.cleaned_data.get('site')
-        vlan_group = self.cleaned_data.get('vlan_group')
-        vlan_vid = self.cleaned_data.get('vlan_vid')
-
-        # Validate VLAN
-        if vlan_group and vlan_vid:
-            try:
-                self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid)
-            except VLAN.DoesNotExist:
-                if site:
-                    raise forms.ValidationError("VLAN {} not found in site {} group {}".format(
-                        vlan_vid, site, vlan_group
-                    ))
-                else:
-                    raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group))
-            except MultipleObjectsReturned:
-                raise forms.ValidationError(
-                    "Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group)
-                )
-        elif vlan_vid:
-            try:
-                self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid)
-            except VLAN.DoesNotExist:
-                if site:
-                    raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site))
-                else:
-                    raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid))
-            except MultipleObjectsReturned:
-                raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid))
+            # Limit vlan queryset by assigned site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"group__{self.fields['vlan_group'].to_field_name}": data.get('vlan_group'),
+            }
+            self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
 
 
 class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -737,23 +693,17 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class IPAddressCSVForm(CustomFieldModelCSVForm):
-    vrf = FlexibleModelChoiceField(
+    vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of parent VRF (or {ID})',
-        error_messages={
-            'invalid_choice': 'VRF not found.',
-        }
+        help_text='Assigned VRF'
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of the assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
     status = CSVChoiceField(
         choices=IPAddressStatusChoices,
@@ -764,27 +714,23 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         required=False,
         help_text='Functional role'
     )
-    device = FlexibleModelChoiceField(
+    device = CSVModelChoiceField(
         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.',
-        }
+        help_text='Parent device of assigned interface (if any)'
     )
-    virtual_machine = forms.ModelChoiceField(
+    virtual_machine = CSVModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of assigned virtual machine',
-        error_messages={
-            'invalid_choice': 'Virtual machine not found.',
-        }
+        help_text='Parent VM of assigned interface (if any)'
     )
-    interface_name = forms.CharField(
-        help_text='Name of assigned interface',
-        required=False
+    interface = CSVModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned interface'
     )
     is_primary = forms.BooleanField(
         help_text='Make this the primary IP for the assigned device',
@@ -795,38 +741,34 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         model = IPAddress
         fields = IPAddress.csv_headers
 
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit interface queryset by assigned device or virtual machine
+            if data.get('device'):
+                params = {
+                    f"device__{self.fields['device'].to_field_name}": data.get('device')
+                }
+            elif data.get('virtual_machine'):
+                params = {
+                    f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
+                }
+            else:
+                params = {
+                    'device': None,
+                    'virtual_machine': None,
+                }
+            self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
+
     def clean(self):
         super().clean()
 
         device = self.cleaned_data.get('device')
         virtual_machine = self.cleaned_data.get('virtual_machine')
-        interface_name = self.cleaned_data.get('interface_name')
         is_primary = self.cleaned_data.get('is_primary')
 
-        # Validate interface
-        if interface_name and device:
-            try:
-                self.instance.interface = Interface.objects.get(device=device, name=interface_name)
-            except Interface.DoesNotExist:
-                raise forms.ValidationError("Invalid interface {} for device {}".format(
-                    interface_name, device
-                ))
-        elif interface_name and virtual_machine:
-            try:
-                self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name)
-            except Interface.DoesNotExist:
-                raise forms.ValidationError("Invalid interface {} for virtual machine {}".format(
-                    interface_name, virtual_machine
-                ))
-        elif interface_name:
-            raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format(
-                interface_name
-            ))
-        elif device:
-            raise forms.ValidationError("Device specified ({}) but interface missing".format(device))
-        elif virtual_machine:
-            raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine))
-
         # Validate is_primary
         if is_primary and not device and not virtual_machine:
             raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
@@ -993,24 +935,18 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class VLANGroupCSVForm(forms.ModelForm):
-    site = forms.ModelChoiceField(
+class VLANGroupCSVForm(CSVModelForm):
+    site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of parent site',
-        error_messages={
-            'invalid_choice': 'Site not found.',
-        }
+        help_text='Assigned site'
     )
     slug = SlugField()
 
     class Meta:
         model = VLANGroup
         fields = VLANGroup.csv_headers
-        help_texts = {
-            'name': 'Name of VLAN group',
-        }
 
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
@@ -1082,40 +1018,33 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class VLANCSVForm(CustomFieldModelCSVForm):
-    site = forms.ModelChoiceField(
+    site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of parent site',
-        error_messages={
-            'invalid_choice': 'Site not found.',
-        }
+        help_text='Assigned site'
     )
-    group_name = forms.CharField(
-        help_text='Name of VLAN group',
-        required=False
+    group = CSVModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned VLAN group'
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
     status = CSVChoiceField(
         choices=VLANStatusChoices,
         help_text='Operational status'
     )
-    role = forms.ModelChoiceField(
+    role = CSVModelChoiceField(
         queryset=Role.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Functional role',
-        error_messages={
-            'invalid_choice': 'Invalid role.',
-        }
+        help_text='Functional role'
     )
 
     class Meta:
@@ -1126,25 +1055,14 @@ class VLANCSVForm(CustomFieldModelCSVForm):
             'name': 'VLAN name',
         }
 
-    def clean(self):
-        super().clean()
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
 
-        site = self.cleaned_data.get('site')
-        group_name = self.cleaned_data.get('group_name')
+        if data:
 
-        # Validate VLAN group
-        if group_name:
-            try:
-                self.instance.group = VLANGroup.objects.get(site=site, name=group_name)
-            except VLANGroup.DoesNotExist:
-                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)
-                    )
+            # Limit vlan queryset by assigned group
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['group'].queryset = self.fields['group'].queryset.filter(**params)
 
 
 class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -1299,23 +1217,17 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
 
 
 class ServiceCSVForm(CustomFieldModelCSVForm):
-    device = FlexibleModelChoiceField(
+    device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name or ID of device',
-        error_messages={
-            'invalid_choice': 'Device not found.',
-        }
+        help_text='Required if not assigned to a VM'
     )
-    virtual_machine = FlexibleModelChoiceField(
+    virtual_machine = CSVModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name or ID of virtual machine',
-        error_messages={
-            'invalid_choice': 'Virtual machine not found.',
-        }
+        help_text='Required if not assigned to a device'
     )
     protocol = CSVChoiceField(
         choices=ServiceProtocolChoices,
@@ -1325,8 +1237,6 @@ class ServiceCSVForm(CustomFieldModelCSVForm):
     class Meta:
         model = Service
         fields = Service.csv_headers
-        help_texts = {
-        }
 
 
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 9 - 5
netbox/ipam/models.py

@@ -50,7 +50,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         unique=True,
         blank=True,
         null=True,
-        verbose_name='Route distinguisher'
+        verbose_name='Route distinguisher',
+        help_text='Unique route distinguisher (as defined in RFC 4364)'
     )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
@@ -364,7 +365,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
-        'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
+        'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
     ]
     clone_fields = [
         'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
@@ -635,7 +636,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
-        'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
+        'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
         'dns_name', 'description',
     ]
     clone_fields = [
@@ -925,7 +926,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
 
     tags = TaggableManager(through=TaggedItem)
 
-    csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+    csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
     clone_fields = [
         'site', 'group', 'tenant', 'status', 'role', 'description',
     ]
@@ -1017,7 +1018,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         choices=ServiceProtocolChoices
     )
     port = models.PositiveIntegerField(
-        validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)],
+        validators=[
+            MinValueValidator(SERVICE_PORT_MIN),
+            MaxValueValidator(SERVICE_PORT_MAX)
+        ],
         verbose_name='Port number'
     )
     ipaddresses = models.ManyToManyField(

+ 7 - 16
netbox/secrets/forms.py

@@ -8,8 +8,8 @@ from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
 )
 from utilities.forms import (
-    APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
+    APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
 )
 from .constants import *
 from .models import Secret, SecretRole, UserKey
@@ -55,15 +55,12 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class SecretRoleCSVForm(forms.ModelForm):
+class SecretRoleCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
         model = SecretRole
         fields = SecretRole.csv_headers
-        help_texts = {
-            'name': 'Name of secret role',
-        }
 
 
 #
@@ -120,21 +117,15 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
 
 
 class SecretCSVForm(CustomFieldModelCSVForm):
-    device = FlexibleModelChoiceField(
+    device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
-        help_text='Device name or ID',
-        error_messages={
-            'invalid_choice': 'Device not found.',
-        }
+        help_text='Assigned device'
     )
-    role = forms.ModelChoiceField(
+    role = CSVModelChoiceField(
         queryset=SecretRole.objects.all(),
         to_field_name='name',
-        help_text='Name of assigned role',
-        error_messages={
-            'invalid_choice': 'Invalid secret role.',
-        }
+        help_text='Assigned role'
     )
     plaintext = forms.CharField(
         help_text='Plaintext secret data'

+ 86 - 49
netbox/templates/utilities/obj_bulk_import.html

@@ -3,58 +3,95 @@
 {% load form_helpers %}
 
 {% block content %}
-<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
 {% block tabs %}{% endblock %}
-<div class="row">
-	<div class="col-md-7">
-        {% if form.non_field_errors %}
-            <div class="panel panel-danger">
-                <div class="panel-heading"><strong>Errors</strong></div>
-                <div class="panel-body">
-                    {{ form.non_field_errors }}
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
+            {% if form.non_field_errors %}
+                <div class="panel panel-danger">
+                    <div class="panel-heading"><strong>Errors</strong></div>
+                    <div class="panel-body">
+                        {{ form.non_field_errors }}
+                    </div>
                 </div>
-            </div>
-        {% endif %}
-		<form action="" method="post" class="form">
-		    {% csrf_token %}
-		    {% render_form form %}
-            <div class="form-group">
-                <div class="col-md-12 text-right">
-		            <button type="submit" class="btn btn-primary">Submit</button>
-		            {% if return_url %}
-                        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            {% endif %}
+            <ul class="nav nav-tabs" role="tablist">
+                <li role="presentation" class="active"><a href="#csv" role="tab" data-toggle="tab">CSV</a></li>
+            </ul>
+            <div class="tab-content">
+                <div role="tabpanel" class="tab-pane active" id="csv">
+                    <form action="" method="post" class="form">
+                        {% csrf_token %}
+                        {% render_form form %}
+                        <div class="form-group">
+                            <div class="col-md-12 text-right">
+                                <button type="submit" class="btn btn-primary">Submit</button>
+                                {% if return_url %}
+                                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                                {% endif %}
+                            </div>
+                        </div>
+                    </form>
+                    <div class="clearfix"></div>
+                    <p></p>
+                    {% if fields %}
+                        <div class="panel panel-default">
+                            <div class="panel-heading">
+                                <strong>CSV Field Options</strong>
+                            </div>
+                            <table class="table">
+                                <tr>
+                                    <th>Field</th>
+                                    <th>Required</th>
+                                    <th>Accessor</th>
+                                    <th>Description</th>
+                                </tr>
+                                {% for name, field in fields.items %}
+                                    <tr>
+                                        <td>
+                                            <code>{{ name }}</code>
+                                        </td>
+                                        <td>
+                                            {% if field.required %}
+                                                <i class="fa fa-check text-success" title="Required"></i>
+                                            {% else %}
+                                                <span class="text-muted">&mdash;</span>
+                                            {% endif %}
+                                        </td>
+                                        <td>
+                                            {% if field.to_field_name %}
+                                                <code>{{ field.to_field_name }}</code>
+                                            {% else %}
+                                                <span class="text-muted">&mdash;</span>
+                                            {% endif %}
+                                        </td>
+                                        <td>
+                                            {% if field.help_text %}
+                                                {{ field.help_text }}<br />
+                                            {% elif field.label %}
+                                                {{ field.label }}<br />
+                                            {% endif %}
+                                            {% if field|widget_type == 'dateinput' %}
+                                                <small class="text-muted">Format: YYYY-MM-DD</small>
+                                            {% elif field|widget_type == 'checkboxinput' %}
+                                                <small class="text-muted">Specify "true" or "false"</small>
+                                            {% endif %}
+                                        </td>
+                                    </tr>
+                                {% endfor %}
+                            </table>
+                        </div>
+                        <p class="small text-muted">
+                            <i class="fa fa-check"></i> Required fields <strong>must</strong> be specified for all
+                            objects.
+                        </p>
+                        <p class="small text-muted">
+                            <i class="fa fa-info-circle"></i> Related objects may be referenced by any unique attribute.
+                            For example, <code>vrf.rd</code> would identify a VRF by its route distinguisher.
+                        </p>
                     {% endif %}
                 </div>
             </div>
-		</form>
-	</div>
-	<div class="col-md-5">
-        {% if fields %}
-            <h4 class="text-center">CSV Format</h4>
-            <table class="table">
-                <tr>
-                    <th>Field</th>
-                    <th>Required</th>
-                    <th>Description</th>
-                </tr>
-                {% for name, field in fields.items %}
-                    <tr>
-                        <td><code>{{ name }}</code></td>
-                        <td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
-                        <td>
-                            {{ field.help_text|default:field.label }}
-                            {% if field.choices %}
-                                <br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
-                            {% elif field|widget_type == 'dateinput' %}
-                                <br /><small class="text-muted">Format: YYYY-MM-DD</small>
-                            {% elif field|widget_type == 'checkboxinput' %}
-                                <br /><small class="text-muted">Specify "true" or "false"</small>
-                            {% endif %}
-                        </td>
-                    </tr>
-                {% endfor %}
-            </table>
-        {% endif %}
-	</div>
-</div>
+        </div>
+    </div>
 {% endblock %}

+ 9 - 22
netbox/tenancy/forms.py

@@ -2,11 +2,11 @@ from django import forms
 from taggit.forms import TagField
 
 from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
+    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
 )
 from utilities.forms import (
-    APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, SlugField, TagFilterField,
+    APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
 )
 from .models import Tenant, TenantGroup
 
@@ -32,24 +32,18 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class TenantGroupCSVForm(forms.ModelForm):
-    parent = forms.ModelChoiceField(
+class TenantGroupCSVForm(CSVModelForm):
+    parent = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of parent tenant group',
-        error_messages={
-            'invalid_choice': 'Tenant group not found.',
-        }
+        help_text='Parent group'
     )
     slug = SlugField()
 
     class Meta:
         model = TenantGroup
         fields = TenantGroup.csv_headers
-        help_texts = {
-            'name': 'Group name',
-        }
 
 
 #
@@ -74,25 +68,18 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
         )
 
 
-class TenantCSVForm(CustomFieldModelForm):
+class TenantCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
-    group = forms.ModelChoiceField(
+    group = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of parent group',
-        error_messages={
-            'invalid_choice': 'Group not found.'
-        }
+        help_text='Assigned group'
     )
 
     class Meta:
         model = Tenant
         fields = Tenant.csv_headers
-        help_texts = {
-            'name': 'Tenant name',
-            'comments': 'Free-form comments'
-        }
 
 
 class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):

+ 84 - 47
netbox/utilities/forms.py

@@ -8,6 +8,7 @@ import yaml
 from django import forms
 from django.conf import settings
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
+from django.core.exceptions import MultipleObjectsReturned
 from django.db.models import Count
 from django.forms import BoundField
 from django.forms.models import fields_for_model
@@ -400,15 +401,22 @@ class TimePicker(forms.TextInput):
 
 class CSVDataField(forms.CharField):
     """
-    A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
-    column headers to values. Each dictionary represents an individual record.
+    A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
+    item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
+    (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
+
+    :param from_form: The form from which the field derives its validation rules.
     """
     widget = forms.Textarea
 
-    def __init__(self, fields, required_fields=[], *args, **kwargs):
+    def __init__(self, from_form, *args, **kwargs):
 
-        self.fields = fields
-        self.required_fields = required_fields
+        form = from_form()
+        self.model = form.Meta.model
+        self.fields = form.fields
+        self.required_fields = [
+            name for name, field in form.fields.items() if field.required
+        ]
 
         super().__init__(*args, **kwargs)
 
@@ -416,7 +424,7 @@ class CSVDataField(forms.CharField):
         if not self.label:
             self.label = ''
         if not self.initial:
-            self.initial = ','.join(required_fields) + '\n'
+            self.initial = ','.join(self.required_fields) + '\n'
         if not self.help_text:
             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 ' \
@@ -425,36 +433,55 @@ class CSVDataField(forms.CharField):
     def to_python(self, value):
 
         records = []
-        reader = csv.reader(StringIO(value))
+        reader = csv.reader(StringIO(value.strip()))
+
+        # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
+        # "to" field specifying how the related object is being referenced. For example, importing a Device might use a
+        # `site.slug` header, to indicate the related site is being referenced by its slug.
+        headers = {}
+        for header in next(reader):
+            if '.' in header:
+                field, to_field = header.split('.', 1)
+                headers[field] = to_field
+            else:
+                headers[header] = None
 
-        # Consume and validate the first line of CSV data as column headers
-        headers = next(reader)
+        # Parse CSV rows into a list of dictionaries mapped from the column headers.
+        for i, row in enumerate(reader, start=1):
+            if len(row) != len(headers):
+                raise forms.ValidationError(
+                    f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
+                )
+            row = [col.strip() for col in row]
+            record = dict(zip(headers.keys(), row))
+            records.append(record)
+
+        return headers, records
+
+    def validate(self, value):
+        headers, records = value
+
+        # Validate provided column headers
+        for field, to_field in headers.items():
+            if field not in self.fields:
+                raise forms.ValidationError(f'Unexpected column header "{field}" found.')
+            if to_field and not hasattr(self.fields[field], 'to_field_name'):
+                raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
+            if to_field and not hasattr(self.fields[field].queryset.model, to_field):
+                raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
+
+        # Validate required fields
         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))
+                raise forms.ValidationError(f'Required column header "{f}" not found.')
 
-        # Parse CSV data
-        for i, row in enumerate(reader, start=1):
-            if row:
-                if len(row) != len(headers):
-                    raise forms.ValidationError(
-                        "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
-                    )
-                row = [col.strip() for col in row]
-                record = dict(zip(headers, row))
-                records.append(record)
-
-        return records
+        return value
 
 
 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().__init__(choices=choices, *args, **kwargs)
         self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
@@ -469,6 +496,23 @@ class CSVChoiceField(forms.ChoiceField):
         return self.choice_values[value]
 
 
+class CSVModelChoiceField(forms.ModelChoiceField):
+    """
+    Provides additional validation for model choices entered as CSV data.
+    """
+    default_error_messages = {
+        'invalid_choice': 'Object not found.',
+    }
+
+    def to_python(self, value):
+        try:
+            return super().to_python(value)
+        except MultipleObjectsReturned as e:
+            raise forms.ValidationError(
+                f'"{value}" is not a unique value for this field; multiple objects were found'
+            )
+
+
 class ExpandableNameField(forms.CharField):
     """
     A field which allows for numeric range expansion
@@ -530,27 +574,6 @@ class CommentField(forms.CharField):
         super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
 
 
-class FlexibleModelChoiceField(forms.ModelChoiceField):
-    """
-    Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`.
-    """
-    def to_python(self, value):
-        if value in self.empty_values:
-            return None
-        try:
-            if not self.to_field_name:
-                key = 'pk'
-            elif re.match(r'^\{\d+\}$', value):
-                key = 'pk'
-                value = value.strip('{}')
-            else:
-                key = self.to_field_name
-            value = self.queryset.get(**{key: value})
-        except (ValueError, TypeError, self.queryset.model.DoesNotExist):
-            raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice')
-        return value
-
-
 class SlugField(forms.SlugField):
     """
     Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
@@ -709,6 +732,20 @@ class BulkEditForm(forms.Form):
             self.nullable_fields = self.Meta.nullable_fields
 
 
+class CSVModelForm(forms.ModelForm):
+    """
+    ModelForm used for the import of objects in CSV format.
+    """
+    def __init__(self, *args, headers=None, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Modify the model form to accommodate any customized to_field_name properties
+        if headers:
+            for field, to_field in headers.items():
+                if to_field is not None:
+                    self.fields[field].to_field_name = to_field
+
+
 class ImportForm(BootstrapMixin, forms.Form):
     """
     Generic form for creating an object from JSON/YAML data

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

@@ -116,28 +116,6 @@ def humanize_speed(speed):
         return '{} Kbps'.format(speed)
 
 
-@register.filter()
-def example_choices(field, arg=3):
-    """
-    Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms).
-    """
-    examples = []
-    if hasattr(field, 'queryset'):
-        choices = [
-            (obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]
-        ]
-    else:
-        choices = field.choices
-    for value, label in unpack_grouped_choices(choices):
-        if len(examples) == arg:
-            examples.append('etc.')
-            break
-        if not value or not label:
-            continue
-        examples.append(label)
-    return ', '.join(examples) or 'None'
-
-
 @register.filter()
 def tzoffset(value):
     """

+ 84 - 0
netbox/utilities/tests/test_forms.py

@@ -1,6 +1,8 @@
 from django import forms
 from django.test import TestCase
 
+from ipam.forms import IPAddressCSVForm
+from ipam.models import VRF
 from utilities.forms import *
 
 
@@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase):
 
         with self.assertRaises(ValueError):
             sorted(expand_alphanumeric_pattern('r[a,,b]a'))
+
+
+class CSVDataFieldTest(TestCase):
+
+    def setUp(self):
+        self.field = CSVDataField(from_form=IPAddressCSVForm)
+
+    def test_clean(self):
+        input = """
+        address,status,vrf
+        192.0.2.1/32,Active,Test VRF
+        """
+        output = (
+            {'address': None, 'status': None, 'vrf': None},
+            [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
+        )
+        self.assertEqual(self.field.clean(input), output)
+
+    def test_clean_invalid_header(self):
+        input = """
+        address,status,vrf,xxx
+        192.0.2.1/32,Active,Test VRF,123
+        """
+        with self.assertRaises(forms.ValidationError):
+            self.field.clean(input)
+
+    def test_clean_missing_required_header(self):
+        input = """
+        status,vrf
+        Active,Test VRF
+        """
+        with self.assertRaises(forms.ValidationError):
+            self.field.clean(input)
+
+    def test_clean_default_to_field(self):
+        input = """
+        address,status,vrf.name
+        192.0.2.1/32,Active,Test VRF
+        """
+        output = (
+            {'address': None, 'status': None, 'vrf': 'name'},
+            [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
+        )
+        self.assertEqual(self.field.clean(input), output)
+
+    def test_clean_pk_to_field(self):
+        input = """
+        address,status,vrf.pk
+        192.0.2.1/32,Active,123
+        """
+        output = (
+            {'address': None, 'status': None, 'vrf': 'pk'},
+            [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123'}]
+        )
+        self.assertEqual(self.field.clean(input), output)
+
+    def test_clean_custom_to_field(self):
+        input = """
+        address,status,vrf.rd
+        192.0.2.1/32,Active,123:456
+        """
+        output = (
+            {'address': None, 'status': None, 'vrf': 'rd'},
+            [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123:456'}]
+        )
+        self.assertEqual(self.field.clean(input), output)
+
+    def test_clean_invalid_to_field(self):
+        input = """
+        address,status,vrf.xxx
+        192.0.2.1/32,Active,123:456
+        """
+        with self.assertRaises(forms.ValidationError):
+            self.field.clean(input)
+
+    def test_clean_to_field_on_non_object(self):
+        input = """
+        address,status.foo,vrf
+        192.0.2.1/32,Bar,Test VRF
+        """
+        with self.assertRaises(forms.ValidationError):
+            self.field.clean(input)

+ 8 - 6
netbox/utilities/views.py

@@ -575,11 +575,11 @@ class BulkImportView(GetReturnURLMixin, View):
 
     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, widget=Textarea(attrs=self.widget_attrs))
+            csv = CSVDataField(
+                from_form=self.model_form,
+                widget=Textarea(attrs=self.widget_attrs)
+            )
 
         return ImportForm(*args, **kwargs)
 
@@ -609,8 +609,10 @@ class BulkImportView(GetReturnURLMixin, View):
             try:
                 # Iterate through CSV data and bind each row to a new model form instance.
                 with transaction.atomic():
-                    for row, data in enumerate(form.cleaned_data['csv'], start=1):
-                        obj_form = self.model_form(data)
+                    headers, records = form.cleaned_data['csv']
+                    for row, data in enumerate(records, start=1):
+                        obj_form = self.model_form(data, headers=headers)
+
                         if obj_form.is_valid():
                             obj = self._save_obj(obj_form, request)
                             new_objs.append(obj)

+ 21 - 51
netbox/virtualization/forms.py

@@ -14,9 +14,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple,
-    TagFilterField,
+    CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
+    StaticSelect2, StaticSelect2Multiple, TagFilterField,
 )
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -36,15 +36,12 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class ClusterTypeCSVForm(forms.ModelForm):
+class ClusterTypeCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
         model = ClusterType
         fields = ClusterType.csv_headers
-        help_texts = {
-            'name': 'Name of cluster type',
-        }
 
 
 #
@@ -61,15 +58,12 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class ClusterGroupCSVForm(forms.ModelForm):
+class ClusterGroupCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
         model = ClusterGroup
         fields = ClusterGroup.csv_headers
-        help_texts = {
-            'name': 'Name of cluster group',
-        }
 
 
 #
@@ -101,40 +95,28 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class ClusterCSVForm(CustomFieldModelCSVForm):
-    type = forms.ModelChoiceField(
+    type = CSVModelChoiceField(
         queryset=ClusterType.objects.all(),
         to_field_name='name',
-        help_text='Name of cluster type',
-        error_messages={
-            'invalid_choice': 'Invalid cluster type name.',
-        }
+        help_text='Type of cluster'
     )
-    group = forms.ModelChoiceField(
+    group = CSVModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of cluster group',
-        error_messages={
-            'invalid_choice': 'Invalid cluster group name.',
-        }
+        help_text='Assigned cluster group'
     )
-    site = forms.ModelChoiceField(
+    site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of assigned site',
-        error_messages={
-            'invalid_choice': 'Invalid site name.',
-        }
+        help_text='Assigned site'
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         to_field_name='name',
         required=False,
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Invalid tenant name'
-        }
+        help_text='Assigned tenant'
     )
 
     class Meta:
@@ -407,42 +389,30 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
         required=False,
         help_text='Operational status of device'
     )
-    cluster = forms.ModelChoiceField(
+    cluster = CSVModelChoiceField(
         queryset=Cluster.objects.all(),
         to_field_name='name',
-        help_text='Name of parent cluster',
-        error_messages={
-            'invalid_choice': 'Invalid cluster name.',
-        }
+        help_text='Assigned cluster'
     )
-    role = forms.ModelChoiceField(
+    role = CSVModelChoiceField(
         queryset=DeviceRole.objects.filter(
             vm_role=True
         ),
         required=False,
         to_field_name='name',
-        help_text='Name of functional role',
-        error_messages={
-            'invalid_choice': 'Invalid role name.'
-        }
+        help_text='Functional role'
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.'
-        }
+        help_text='Assigned tenant'
     )
-    platform = forms.ModelChoiceField(
+    platform = CSVModelChoiceField(
         queryset=Platform.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Name of assigned platform',
-        error_messages={
-            'invalid_choice': 'Invalid platform.',
-        }
+        help_text='Assigned platform'
     )
 
     class Meta:

Некоторые файлы не были показаны из-за большого количества измененных файлов