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

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.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 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 .choices import CircuitStatusChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -55,12 +55,6 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = Provider.csv_headers
         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):
 class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -148,7 +142,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
         ]
         ]
 
 
 
 
-class CircuitTypeCSVForm(forms.ModelForm):
+class CircuitTypeCSVForm(CSVModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -192,35 +186,26 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 
 
 class CircuitCSVForm(CustomFieldModelCSVForm):
 class CircuitCSVForm(CustomFieldModelCSVForm):
-    provider = forms.ModelChoiceField(
+    provider = CSVModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='name',
         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(),
         queryset=CircuitType.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        help_text='Type of circuit',
-        error_messages={
-            'invalid_choice': 'Invalid circuit type.'
-        }
+        help_text='Type of circuit'
     )
     )
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=CircuitStatusChoices,
         choices=CircuitStatusChoices,
         required=False,
         required=False,
         help_text='Operational status'
         help_text='Operational status'
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.'
-        }
+        help_text='Assigned tenant'
     )
     )
 
 
     class Meta:
     class Meta:

+ 3 - 2
netbox/circuits/models.py

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

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

@@ -239,7 +239,8 @@ class ConsolePort(CableTermination, ComponentModel):
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     )
     connected_endpoint = models.OneToOneField(
     connected_endpoint = models.OneToOneField(
         to='dcim.ConsoleServerPort',
         to='dcim.ConsoleServerPort',
@@ -300,7 +301,8 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     )
     connection_status = models.NullBooleanField(
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
@@ -354,7 +356,8 @@ class PowerPort(CableTermination, ComponentModel):
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     )
     maximum_draw = models.PositiveSmallIntegerField(
     maximum_draw = models.PositiveSmallIntegerField(
         blank=True,
         blank=True,
@@ -516,7 +519,8 @@ class PowerOutlet(CableTermination, ComponentModel):
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
-        blank=True
+        blank=True,
+        help_text='Physical port type'
     )
     )
     power_port = models.ForeignKey(
     power_port = models.ForeignKey(
         to='dcim.PowerPort',
         to='dcim.PowerPort',
@@ -653,7 +657,7 @@ class Interface(CableTermination, ComponentModel):
     mode = models.CharField(
     mode = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfaceModeChoices,
         choices=InterfaceModeChoices,
-        blank=True,
+        blank=True
     )
     )
     untagged_vlan = models.ForeignKey(
     untagged_vlan = models.ForeignKey(
         to='ipam.VLAN',
         to='ipam.VLAN',
@@ -1083,7 +1087,8 @@ class InventoryItem(ComponentModel):
     part_id = models.CharField(
     part_id = models.CharField(
         max_length=50,
         max_length=50,
         verbose_name='Part ID',
         verbose_name='Part ID',
-        blank=True
+        blank=True,
+        help_text='Manufacturer-assigned part identifier'
     )
     )
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
@@ -1100,7 +1105,7 @@ class InventoryItem(ComponentModel):
     )
     )
     discovered = models.BooleanField(
     discovered = models.BooleanField(
         default=False,
         default=False,
-        verbose_name='Discovered'
+        help_text='This item was automatically discovered'
     )
     )
 
 
     tags = TaggableManager(through=TaggedItem)
     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')
         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()
         rack.save()
 
 
         RackReservation.objects.bulk_create([
         RackReservation.objects.bulk_create([
@@ -202,10 +205,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = {
         cls.bulk_edit_data = {
@@ -268,10 +271,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = {
         cls.bulk_edit_data = {
@@ -890,8 +893,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
+        rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1')
+        rack_group.save()
+
         racks = (
         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(name='Rack 2', site=sites[1]),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
@@ -947,10 +953,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = {
         cls.bulk_edit_data = {
@@ -1586,7 +1592,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 4",
             "Site 1,Rack Group 1,Power Panel 5",
             "Site 1,Rack Group 1,Power Panel 5",
             "Site 1,Rack Group 1,Power Panel 6",
             "Site 1,Rack Group 1,Power Panel 6",
@@ -1645,7 +1651,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 4,120,20,80",
             "Site 1,Power Panel 1,Power Feed 5,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",
             "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 tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     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,
     StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
@@ -89,7 +89,7 @@ class CustomFieldModelForm(forms.ModelForm):
         return obj
         return obj
 
 
 
 
-class CustomFieldModelCSVForm(CustomFieldModelForm):
+class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
 
 
     def _append_customfield_fields(self):
     def _append_customfield_fields(self):
 
 

+ 94 - 184
netbox/ipam/forms.py

@@ -1,5 +1,4 @@
 from django import forms
 from django import forms
-from django.core.exceptions import MultipleObjectsReturned
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
@@ -11,16 +10,15 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
     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,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
-from .constants import *
 from .choices import *
 from .choices import *
+from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
-
 PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
 PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
     (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
     (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):
 class VRFCSVForm(CustomFieldModelCSVForm):
-    tenant = forms.ModelChoiceField(
+    tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
     )
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = VRF.csv_headers
         fields = VRF.csv_headers
-        help_texts = {
-            'name': 'VRF name',
-        }
 
 
 
 
 class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
 class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -120,7 +112,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
         ]
         ]
 
 
 
 
-class RIRCSVForm(forms.ModelForm):
+class RIRCSVForm(CSVModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -168,13 +160,10 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
 
 
 
 
 class AggregateCSVForm(CustomFieldModelCSVForm):
 class AggregateCSVForm(CustomFieldModelCSVForm):
-    rir = forms.ModelChoiceField(
+    rir = CSVModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        help_text='Name of parent RIR',
-        error_messages={
-            'invalid_choice': 'RIR not found.',
-        }
+        help_text='Assigned RIR'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -247,15 +236,12 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
         ]
         ]
 
 
 
 
-class RoleCSVForm(forms.ModelForm):
+class RoleCSVForm(CSVModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
         fields = Role.csv_headers
         fields = Role.csv_headers
-        help_texts = {
-            'name': 'Role name',
-        }
 
 
 
 
 #
 #
@@ -333,92 +319,62 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 
 
 class PrefixCSVForm(CustomFieldModelCSVForm):
 class PrefixCSVForm(CustomFieldModelCSVForm):
-    vrf = FlexibleModelChoiceField(
+    vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         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(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(
     status = CSVChoiceField(
         choices=PrefixStatusChoices,
         choices=PrefixStatusChoices,
         help_text='Operational status'
         help_text='Operational status'
     )
     )
-    role = forms.ModelChoiceField(
+    role = CSVModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Functional role',
-        error_messages={
-            'invalid_choice': 'Invalid role.',
-        }
+        help_text='Functional role'
     )
     )
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = Prefix.csv_headers
         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):
 class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -737,23 +693,17 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 
 
 class IPAddressCSVForm(CustomFieldModelCSVForm):
 class IPAddressCSVForm(CustomFieldModelCSVForm):
-    vrf = FlexibleModelChoiceField(
+    vrf = CSVModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         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(),
         queryset=Tenant.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         required=False,
-        help_text='Name of the assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
     )
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=IPAddressStatusChoices,
         choices=IPAddressStatusChoices,
@@ -764,27 +714,23 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         required=False,
         required=False,
         help_text='Functional role'
         help_text='Functional role'
     )
     )
-    device = FlexibleModelChoiceField(
+    device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(),
         queryset=VirtualMachine.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(
     is_primary = forms.BooleanField(
         help_text='Make this the primary IP for the assigned device',
         help_text='Make this the primary IP for the assigned device',
@@ -795,38 +741,34 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         model = IPAddress
         model = IPAddress
         fields = IPAddress.csv_headers
         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):
     def clean(self):
         super().clean()
         super().clean()
 
 
         device = self.cleaned_data.get('device')
         device = self.cleaned_data.get('device')
         virtual_machine = self.cleaned_data.get('virtual_machine')
         virtual_machine = self.cleaned_data.get('virtual_machine')
-        interface_name = self.cleaned_data.get('interface_name')
         is_primary = self.cleaned_data.get('is_primary')
         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
         # Validate is_primary
         if is_primary and not device and not virtual_machine:
         if is_primary and not device and not virtual_machine:
             raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
             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(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Name of parent site',
-        error_messages={
-            'invalid_choice': 'Site not found.',
-        }
+        help_text='Assigned site'
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = VLANGroup.csv_headers
         fields = VLANGroup.csv_headers
-        help_texts = {
-            'name': 'Name of VLAN group',
-        }
 
 
 
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
@@ -1082,40 +1018,33 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 
 
 class VLANCSVForm(CustomFieldModelCSVForm):
 class VLANCSVForm(CustomFieldModelCSVForm):
-    site = forms.ModelChoiceField(
+    site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(),
         queryset=Tenant.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         required=False,
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Tenant not found.',
-        }
+        help_text='Assigned tenant'
     )
     )
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=VLANStatusChoices,
         choices=VLANStatusChoices,
         help_text='Operational status'
         help_text='Operational status'
     )
     )
-    role = forms.ModelChoiceField(
+    role = CSVModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Functional role',
-        error_messages={
-            'invalid_choice': 'Invalid role.',
-        }
+        help_text='Functional role'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1126,25 +1055,14 @@ class VLANCSVForm(CustomFieldModelCSVForm):
             'name': 'VLAN name',
             '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):
 class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -1299,23 +1217,17 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
 
 
 
 
 class ServiceCSVForm(CustomFieldModelCSVForm):
 class ServiceCSVForm(CustomFieldModelCSVForm):
-    device = FlexibleModelChoiceField(
+    device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(),
         queryset=VirtualMachine.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(
     protocol = CSVChoiceField(
         choices=ServiceProtocolChoices,
         choices=ServiceProtocolChoices,
@@ -1325,8 +1237,6 @@ class ServiceCSVForm(CustomFieldModelCSVForm):
     class Meta:
     class Meta:
         model = Service
         model = Service
         fields = Service.csv_headers
         fields = Service.csv_headers
-        help_texts = {
-        }
 
 
 
 
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 9 - 5
netbox/ipam/models.py

@@ -50,7 +50,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         unique=True,
         unique=True,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name='Route distinguisher'
+        verbose_name='Route distinguisher',
+        help_text='Unique route distinguisher (as defined in RFC 4364)'
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -364,7 +365,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     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 = [
     clone_fields = [
         'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
         'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
@@ -635,7 +636,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     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',
         'dns_name', 'description',
     ]
     ]
     clone_fields = [
     clone_fields = [
@@ -925,7 +926,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
 
 
     tags = TaggableManager(through=TaggedItem)
     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 = [
     clone_fields = [
         'site', 'group', 'tenant', 'status', 'role', 'description',
         'site', 'group', 'tenant', 'status', 'role', 'description',
     ]
     ]
@@ -1017,7 +1018,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         choices=ServiceProtocolChoices
         choices=ServiceProtocolChoices
     )
     )
     port = models.PositiveIntegerField(
     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'
         verbose_name='Port number'
     )
     )
     ipaddresses = models.ManyToManyField(
     ipaddresses = models.ManyToManyField(

+ 7 - 16
netbox/secrets/forms.py

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

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

@@ -3,58 +3,95 @@
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
-<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
 {% block tabs %}{% endblock %}
 {% 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>
-            </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 %}
                     {% endif %}
                 </div>
                 </div>
             </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 %}
 {% endblock %}

+ 9 - 22
netbox/tenancy/forms.py

@@ -2,11 +2,11 @@ from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from extras.forms import (
 from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
+    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
 )
 )
 from utilities.forms import (
 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
 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(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
         fields = TenantGroup.csv_headers
         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()
     slug = SlugField()
-    group = forms.ModelChoiceField(
+    group = CSVModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Name of parent group',
-        error_messages={
-            'invalid_choice': 'Group not found.'
-        }
+        help_text='Assigned group'
     )
     )
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
         fields = Tenant.csv_headers
         fields = Tenant.csv_headers
-        help_texts = {
-            'name': 'Tenant name',
-            'comments': 'Free-form comments'
-        }
 
 
 
 
 class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
 class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):

+ 84 - 47
netbox/utilities/forms.py

@@ -8,6 +8,7 @@ import yaml
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 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.db.models import Count
 from django.forms import BoundField
 from django.forms import BoundField
 from django.forms.models import fields_for_model
 from django.forms.models import fields_for_model
@@ -400,15 +401,22 @@ class TimePicker(forms.TextInput):
 
 
 class CSVDataField(forms.CharField):
 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
     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)
         super().__init__(*args, **kwargs)
 
 
@@ -416,7 +424,7 @@ class CSVDataField(forms.CharField):
         if not self.label:
         if not self.label:
             self.label = ''
             self.label = ''
         if not self.initial:
         if not self.initial:
-            self.initial = ','.join(required_fields) + '\n'
+            self.initial = ','.join(self.required_fields) + '\n'
         if not self.help_text:
         if not self.help_text:
             self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
             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 ' \
                              '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):
     def to_python(self, value):
 
 
         records = []
         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:
         for f in self.required_fields:
             if f not in headers:
             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):
 class CSVChoiceField(forms.ChoiceField):
     """
     """
     Invert the provided set of choices to take the human-friendly label as input, and return the database value.
     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):
     def __init__(self, choices, *args, **kwargs):
         super().__init__(choices=choices, *args, **kwargs)
         super().__init__(choices=choices, *args, **kwargs)
         self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
         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]
         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):
 class ExpandableNameField(forms.CharField):
     """
     """
     A field which allows for numeric range expansion
     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)
         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):
 class SlugField(forms.SlugField):
     """
     """
     Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
     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
             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):
 class ImportForm(BootstrapMixin, forms.Form):
     """
     """
     Generic form for creating an object from JSON/YAML data
     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)
         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()
 @register.filter()
 def tzoffset(value):
 def tzoffset(value):
     """
     """

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

@@ -1,6 +1,8 @@
 from django import forms
 from django import forms
 from django.test import TestCase
 from django.test import TestCase
 
 
+from ipam.forms import IPAddressCSVForm
+from ipam.models import VRF
 from utilities.forms import *
 from utilities.forms import *
 
 
 
 
@@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase):
 
 
         with self.assertRaises(ValueError):
         with self.assertRaises(ValueError):
             sorted(expand_alphanumeric_pattern('r[a,,b]a'))
             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):
     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):
         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)
         return ImportForm(*args, **kwargs)
 
 
@@ -609,8 +609,10 @@ class BulkImportView(GetReturnURLMixin, View):
             try:
             try:
                 # Iterate through CSV data and bind each row to a new model form instance.
                 # Iterate through CSV data and bind each row to a new model form instance.
                 with transaction.atomic():
                 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():
                         if obj_form.is_valid():
                             obj = self._save_obj(obj_form, request)
                             obj = self._save_obj(obj_form, request)
                             new_objs.append(obj)
                             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 tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     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 .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 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()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
         fields = ClusterType.csv_headers
         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()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
         fields = ClusterGroup.csv_headers
         fields = ClusterGroup.csv_headers
-        help_texts = {
-            'name': 'Name of cluster group',
-        }
 
 
 
 
 #
 #
@@ -101,40 +95,28 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 
 
 class ClusterCSVForm(CustomFieldModelCSVForm):
 class ClusterCSVForm(CustomFieldModelCSVForm):
-    type = forms.ModelChoiceField(
+    type = CSVModelChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         to_field_name='name',
         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(),
         queryset=ClusterGroup.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         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(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         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(),
         queryset=Tenant.objects.all(),
         to_field_name='name',
         to_field_name='name',
         required=False,
         required=False,
-        help_text='Name of assigned tenant',
-        error_messages={
-            'invalid_choice': 'Invalid tenant name'
-        }
+        help_text='Assigned tenant'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -407,42 +389,30 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
         required=False,
         required=False,
         help_text='Operational status of device'
         help_text='Operational status of device'
     )
     )
-    cluster = forms.ModelChoiceField(
+    cluster = CSVModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         to_field_name='name',
         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(
         queryset=DeviceRole.objects.filter(
             vm_role=True
             vm_role=True
         ),
         ),
         required=False,
         required=False,
         to_field_name='name',
         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(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         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(),
         queryset=Platform.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text='Name of assigned platform',
-        error_messages={
-            'invalid_choice': 'Invalid platform.',
-        }
+        help_text='Assigned platform'
     )
     )
 
 
     class Meta:
     class Meta:

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