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

Introduce CSVModelForm for dynamic CSV imports

Jeremy Stretch 5 лет назад
Родитель
Сommit
839e999a71

+ 2 - 2
netbox/circuits/forms.py

@@ -8,7 +8,7 @@ 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,
+    APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelForm, DatePicker,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
     StaticSelect2Multiple, TagFilterField,
 )
@@ -142,7 +142,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class CircuitTypeCSVForm(forms.ModelForm):
+class CircuitTypeCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:

+ 162 - 167
netbox/dcim/forms.py

@@ -23,9 +23,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
-    BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SelectWithPK, SmallTextarea,
-    SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelForm,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
+    SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -193,7 +193,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
         )
 
 
-class RegionCSVForm(forms.ModelForm):
+class RegionCSVForm(CSVModelForm):
     parent = forms.ModelChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -388,7 +388,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
         )
 
 
-class RackGroupCSVForm(forms.ModelForm):
+class RackGroupCSVForm(CSVModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -461,7 +461,7 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class RackRoleCSVForm(forms.ModelForm):
+class RackRoleCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -526,8 +526,13 @@ class RackCSVForm(CustomFieldModelCSVForm):
             'invalid_choice': 'Site not found.',
         }
     )
-    group_name = forms.CharField(
-        required=False
+    group = forms.ModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        error_messages={
+            'invalid_choice': 'Rack group not found.',
+        }
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
@@ -571,33 +576,14 @@ class RackCSVForm(CustomFieldModelCSVForm):
         model = Rack
         fields = Rack.csv_headers
 
-    def clean(self):
-
-        super().clean()
-
-        site = self.cleaned_data.get('site')
-        group_name = self.cleaned_data.get('group_name')
-        name = self.cleaned_data.get('name')
-        facility_id = self.cleaned_data.get('facility_id')
-
-        # Validate rack group
-        if group_name:
-            try:
-                self.instance.group = RackGroup.objects.get(site=site, name=group_name)
-            except RackGroup.DoesNotExist:
-                raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site))
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
 
-            # Validate uniqueness of rack name within group
-            if Rack.objects.filter(group=self.instance.group, name=name).exists():
-                raise forms.ValidationError(
-                    "A rack named {} already exists within group {}".format(name, group_name)
-                )
+        if data:
 
-            # Validate uniqueness of facility ID within group
-            if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists():
-                raise forms.ValidationError(
-                    "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name)
-                )
+            # Limit group queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['group'].queryset = self.fields['group'].queryset.filter(**params)
 
 
 class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -814,21 +800,31 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
         return unit_choices
 
 
-class RackReservationCSVForm(forms.ModelForm):
+class RackReservationCSVForm(CSVModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
         help_text='Parent site',
         error_messages={
-            'invalid_choice': 'Invalid site name.',
+            'invalid_choice': 'Site not found.',
         }
     )
-    rack_group = forms.CharField(
+    rack_group = forms.ModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        to_field_name='name',
         required=False,
-        help_text="Rack's group (if any)"
+        help_text="Rack's group (if any)",
+        error_messages={
+            'invalid_choice': 'Rack group not found.',
+        }
     )
-    rack_name = forms.CharField(
-        help_text="Rack name"
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        to_field_name='name',
+        help_text='Rack',
+        error_messages={
+            'invalid_choice': 'Rack not found.',
+        }
     )
     units = SimpleArrayField(
         base_field=forms.IntegerField(),
@@ -847,27 +843,23 @@ class RackReservationCSVForm(forms.ModelForm):
 
     class Meta:
         model = RackReservation
-        fields = ('site', 'rack_group', 'rack_name', 'units', 'tenant', 'description')
+        fields = ('site', 'rack_group', 'rack', 'units', 'tenant', 'description')
 
-    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')
-        rack_group = self.cleaned_data.get('rack_group')
-        rack_name = self.cleaned_data.get('rack_name')
+            # Limit rack_group queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
 
-        # Validate rack
-        if site and rack_group and rack_name:
-            try:
-                self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
-            except Rack.DoesNotExist:
-                raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group))
-        elif site and rack_name:
-            try:
-                self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name)
-            except Rack.DoesNotExist:
-                raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site))
+            # Limit rack queryset by assigned site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
 class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -933,7 +925,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class ManufacturerCSVForm(forms.ModelForm):
+class ManufacturerCSVForm(CSVModelForm):
 
     class Meta:
         model = Manufacturer
@@ -1648,7 +1640,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class DeviceRoleCSVForm(forms.ModelForm):
+class DeviceRoleCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -1682,7 +1674,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class PlatformCSVForm(forms.ModelForm):
+class PlatformCSVForm(CSVModelForm):
     slug = SlugField()
     manufacturer = forms.ModelChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -1920,11 +1912,16 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
         to_field_name='name',
         help_text='Device type manufacturer',
         error_messages={
-            'invalid_choice': 'Invalid manufacturer.',
+            'invalid_choice': 'Manufacturer not found.',
         }
     )
-    model_name = forms.CharField(
-        help_text='Device type model name'
+    device_type = forms.ModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        to_field_name='model',
+        help_text='Device type model',
+        error_messages={
+            'invalid_choice': 'Device type not found.',
+        }
     )
     platform = forms.ModelChoiceField(
         queryset=Platform.objects.all(),
@@ -1953,19 +1950,14 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
         fields = []
         model = Device
 
-    def clean(self):
-
-        super().clean()
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
 
-        manufacturer = self.cleaned_data.get('manufacturer')
-        model_name = self.cleaned_data.get('model_name')
+        if data:
 
-        # Validate device type
-        if manufacturer and model_name:
-            try:
-                self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
-            except DeviceType.DoesNotExist:
-                raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name))
+            # Limit device type queryset by manufacturer
+            params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
+            self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
 
 
 class DeviceCSVForm(BaseDeviceCSVForm):
@@ -1974,16 +1966,26 @@ class DeviceCSVForm(BaseDeviceCSVForm):
         to_field_name='name',
         help_text='Assigned site',
         error_messages={
-            'invalid_choice': 'Invalid site name.',
+            'invalid_choice': 'Site not found.',
         }
     )
-    rack_group = forms.CharField(
+    rack_group = forms.ModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        to_field_name='name',
         required=False,
-        help_text='Assigned rack\'s group (if any)'
+        help_text="Rack's group (if any)",
+        error_messages={
+            'invalid_choice': 'Rack group not found.',
+        }
     )
-    rack_name = forms.CharField(
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        to_field_name='name',
         required=False,
-        help_text='Name of parent rack'
+        help_text="Assigned rack",
+        error_messages={
+            'invalid_choice': 'Rack not found.',
+        }
     )
     face = CSVChoiceField(
         choices=DeviceFaceChoices,
@@ -1993,29 +1995,25 @@ class DeviceCSVForm(BaseDeviceCSVForm):
 
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
-            'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments',
+            'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
+            'site', 'rack_group', 'rack', 'position', 'face', 'cluster', 'comments',
         ]
 
-    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')
-        rack_group = self.cleaned_data.get('rack_group')
-        rack_name = self.cleaned_data.get('rack_name')
+            # Limit rack_group queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
 
-        # Validate rack
-        if site and rack_group and rack_name:
-            try:
-                self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
-            except Rack.DoesNotExist:
-                raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group))
-        elif site and rack_name:
-            try:
-                self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name)
-            except Rack.DoesNotExist:
-                raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site))
+            # Limit rack queryset by assigned site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
 class ChildDeviceCSVForm(BaseDeviceCSVForm):
@@ -2027,32 +2025,29 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
             'invalid_choice': 'Parent device not found.',
         }
     )
-    device_bay_name = forms.CharField(
-        help_text='Name of device bay',
+    device_bay = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Device bay in which this device is installed',
+        error_messages={
+            'invalid_choice': 'Devie bay not found.',
+        }
     )
 
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
-            'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'parent', 'device_bay_name', 'cluster', 'comments',
+            'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
+            'parent', 'device_bay', 'cluster', 'comments',
         ]
 
-    def clean(self):
-
-        super().clean()
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
 
-        parent = self.cleaned_data.get('parent')
-        device_bay_name = self.cleaned_data.get('device_bay_name')
+        if data:
 
-        # Validate device bay
-        if parent and device_bay_name:
-            try:
-                self.instance.parent_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
-                # Inherit site and rack from parent device
-                self.instance.site = parent.site
-                self.instance.rack = parent.rack
-            except DeviceBay.DoesNotExist:
-                raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name))
+            # Limit device bay queryset by parent device
+            params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
+            self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
 
 
 class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -2344,7 +2339,7 @@ class ConsolePortBulkEditForm(
         )
 
 
-class ConsolePortCSVForm(forms.ModelForm):
+class ConsolePortCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -2447,7 +2442,7 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
     )
 
 
-class ConsoleServerPortCSVForm(forms.ModelForm):
+class ConsoleServerPortCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -2546,7 +2541,7 @@ class PowerPortBulkEditForm(
         )
 
 
-class PowerPortCSVForm(forms.ModelForm):
+class PowerPortCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -2696,7 +2691,7 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
     )
 
 
-class PowerOutletCSVForm(forms.ModelForm):
+class PowerOutletCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -3018,7 +3013,7 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
     )
 
 
-class InterfaceCSVForm(forms.ModelForm):
+class InterfaceCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
@@ -3231,7 +3226,7 @@ class FrontPortBulkDisconnectForm(ConfirmationForm):
     )
 
 
-class FrontPortCSVForm(forms.ModelForm):
+class FrontPortCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -3372,7 +3367,7 @@ class RearPortBulkDisconnectForm(ConfirmationForm):
     )
 
 
-class RearPortCSVForm(forms.ModelForm):
+class RearPortCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -3483,7 +3478,7 @@ class DeviceBayBulkRenameForm(BulkRenameForm):
     )
 
 
-class DeviceBayCSVForm(forms.ModelForm):
+class DeviceBayCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -3774,7 +3769,7 @@ class CableForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class CableCSVForm(forms.ModelForm):
+class CableCSVForm(CSVModelForm):
     # Termination A
     side_a_device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
@@ -4128,7 +4123,7 @@ class InventoryItemCreateForm(BootstrapMixin, forms.Form):
     )
 
 
-class InventoryItemCSVForm(forms.ModelForm):
+class InventoryItemCSVForm(CSVModelForm):
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -4439,7 +4434,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class PowerPanelCSVForm(forms.ModelForm):
+class PowerPanelCSVForm(CSVModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         to_field_name='name',
@@ -4448,30 +4443,27 @@ class PowerPanelCSVForm(forms.ModelForm):
             'invalid_choice': 'Site not found.',
         }
     )
-    rack_group_name = forms.CharField(
+    rack_group = forms.ModelChoiceField(
+        queryset=RackGroup.objects.all(),
         required=False,
-        help_text="Rack group name (optional)"
+        to_field_name='name',
+        error_messages={
+            'invalid_choice': 'Rack group not found.',
+        }
     )
 
     class Meta:
         model = PowerPanel
         fields = PowerPanel.csv_headers
 
-    def clean(self):
-
-        super().clean()
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
 
-        site = self.cleaned_data.get('site')
-        rack_group_name = self.cleaned_data.get('rack_group_name')
+        if data:
 
-        # Validate rack group
-        if rack_group_name:
-            try:
-                self.instance.rack_group = RackGroup.objects.get(site=site, name=rack_group_name)
-            except RackGroup.DoesNotExist:
-                raise forms.ValidationError(
-                    "Rack group {} not found in site {}".format(rack_group_name, site)
-                )
+            # Limit group queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
 
 
 class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -4595,7 +4587,7 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm):
             'invalid_choice': 'Site not found.',
         }
     )
-    panel_name = forms.ModelChoiceField(
+    power_panel = forms.ModelChoiceField(
         queryset=PowerPanel.objects.all(),
         to_field_name='name',
         help_text='Upstream power panel',
@@ -4603,13 +4595,23 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm):
             'invalid_choice': 'Power panel not found.',
         }
     )
-    rack_group = forms.CharField(
+    rack_group = forms.ModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        to_field_name='name',
         required=False,
-        help_text="Assigned rack's group name"
+        help_text="Rack's group (if any)",
+        error_messages={
+            'invalid_choice': 'Rack group not found.',
+        }
     )
-    rack_name = forms.CharField(
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        to_field_name='name',
         required=False,
-        help_text="Assigned rack name"
+        help_text='Rack',
+        error_messages={
+            'invalid_choice': 'Rack not found.',
+        }
     )
     status = CSVChoiceField(
         choices=PowerFeedStatusChoices,
@@ -4636,32 +4638,25 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm):
         model = PowerFeed
         fields = PowerFeed.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')
-        panel_name = self.cleaned_data.get('panel_name')
-        rack_group = self.cleaned_data.get('rack_group')
-        rack_name = self.cleaned_data.get('rack_name')
+            # Limit power_panel queryset by site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
 
-        # Validate power panel
-        if panel_name:
-            try:
-                self.instance.power_panel = PowerPanel.objects.get(site=site, name=panel_name)
-            except Rack.DoesNotExist:
-                raise forms.ValidationError(
-                    "Power panel {} not found in site {}".format(panel_name, site)
-                )
+            # Limit rack_group queryset by site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
 
-        # Validate rack
-        if rack_name:
-            try:
-                self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
-            except Rack.DoesNotExist:
-                raise forms.ValidationError(
-                    "Rack {} not found in site {}, group {}".format(rack_name, site, rack_group)
-                )
+            # Limit rack queryset by site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
 class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):

+ 5 - 5
netbox/dcim/models/__init__.py

@@ -523,7 +523,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 = [
@@ -829,7 +829,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]
@@ -1408,7 +1408,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 = [
@@ -1791,7 +1791,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']
@@ -1898,7 +1898,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 = [

+ 4 - 4
netbox/dcim/tests/test_views.py

@@ -202,7 +202,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            'site,rack_name,units,description',
+            'site,rack,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',
@@ -947,7 +947,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "device_role,manufacturer,model_name,status,site,name",
+            "device_role,manufacturer,device_type,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",
@@ -1586,7 +1586,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 +1645,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):
 

+ 76 - 94
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,8 +10,8 @@ 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, ReturnURLForm,
-    SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+    CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
+    ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import VirtualMachine
 from .choices import *
@@ -115,7 +114,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class RIRCSVForm(forms.ModelForm):
+class RIRCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -242,7 +241,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class RoleCSVForm(forms.ModelForm):
+class RoleCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -352,13 +351,23 @@ class PrefixCSVForm(CustomFieldModelCSVForm):
             'invalid_choice': 'Site not found.',
         }
     )
-    vlan_group = forms.CharField(
-        help_text='Group name of assigned VLAN',
-        required=False
+    vlan_group = forms.ModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text="VLAN's group (if any)",
+        error_messages={
+            'invalid_choice': 'VLAN group not found.',
+        }
     )
-    vlan_vid = forms.IntegerField(
-        help_text='Numeric ID of assigned VLAN',
-        required=False
+    vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text="Assigned VLAN",
+        error_messages={
+            'invalid_choice': 'VLAN not found.',
+        }
     )
     status = CSVChoiceField(
         choices=PrefixStatusChoices,
@@ -378,39 +387,17 @@ class PrefixCSVForm(CustomFieldModelCSVForm):
         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):
@@ -760,7 +747,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         queryset=Device.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Assigned device',
+        help_text='Parent device of assigned interface (if any)',
         error_messages={
             'invalid_choice': 'Device not found.',
         }
@@ -769,14 +756,19 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         queryset=VirtualMachine.objects.all(),
         required=False,
         to_field_name='name',
-        help_text='Assigned virtual machine',
+        help_text='Parent VM of assigned interface (if any)',
         error_messages={
             'invalid_choice': 'Virtual machine not found.',
         }
     )
-    interface_name = forms.CharField(
-        help_text='Name of assigned interface',
-        required=False
+    interface = forms.ModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned interface',
+        error_messages={
+            'invalid_choice': 'Interface not found.',
+        }
     )
     is_primary = forms.BooleanField(
         help_text='Make this the primary IP for the assigned device',
@@ -787,38 +779,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")
@@ -985,7 +973,7 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class VLANGroupCSVForm(forms.ModelForm):
+class VLANGroupCSVForm(CSVModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
@@ -1080,9 +1068,14 @@ class VLANCSVForm(CustomFieldModelCSVForm):
             'invalid_choice': 'Site not found.',
         }
     )
-    group_name = forms.CharField(
-        help_text='Name of VLAN group',
-        required=False
+    group = forms.ModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned VLAN group',
+        error_messages={
+            'invalid_choice': 'VLAN group not found.',
+        }
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
@@ -1115,25 +1108,14 @@ class VLANCSVForm(CustomFieldModelCSVForm):
             'name': 'VLAN name',
         }
 
-    def clean(self):
-        super().clean()
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
 
-        site = self.cleaned_data.get('site')
-        group_name = self.cleaned_data.get('group_name')
-
-        # 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):

+ 3 - 3
netbox/ipam/models.py

@@ -365,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',
@@ -636,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 = [
@@ -926,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',
     ]

+ 3 - 3
netbox/secrets/forms.py

@@ -8,8 +8,8 @@ from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
 )
 from utilities.forms import (
-    APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
-    StaticSelect2Multiple, TagFilterField,
+    APISelectMultiple, BootstrapMixin, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    SlugField, StaticSelect2Multiple, TagFilterField,
 )
 from .constants import *
 from .models import Secret, SecretRole, UserKey
@@ -55,7 +55,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class SecretRoleCSVForm(forms.ModelForm):
+class SecretRoleCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:

+ 4 - 4
netbox/tenancy/forms.py

@@ -2,10 +2,10 @@ 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,
+    APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelForm, DynamicModelChoiceField,
     DynamicModelMultipleChoiceField, SlugField, TagFilterField,
 )
 from .models import Tenant, TenantGroup
@@ -32,7 +32,7 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class TenantGroupCSVForm(forms.ModelForm):
+class TenantGroupCSVForm(CSVModelForm):
     parent = forms.ModelChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
@@ -71,7 +71,7 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
         )
 
 
-class TenantCSVForm(CustomFieldModelForm):
+class TenantCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
     group = forms.ModelChoiceField(
         queryset=TenantGroup.objects.all(),

+ 14 - 0
netbox/utilities/forms.py

@@ -712,6 +712,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

+ 1 - 6
netbox/utilities/views.py

@@ -593,12 +593,7 @@ class BulkImportView(GetReturnURLMixin, View):
                 with transaction.atomic():
                     headers, records = form.cleaned_data['csv']
                     for row, data in enumerate(records, start=1):
-                        obj_form = self.model_form(data)
-
-                        # Modify the model form to accommodate any customized to_field_name properties
-                        for field, to_field in headers.items():
-                            if to_field is not None:
-                                obj_form.fields[field].to_field_name = to_field
+                        obj_form = self.model_form(data, headers=headers)
 
                         if obj_form.is_valid():
                             obj = self._save_obj(obj_form, request)

+ 5 - 5
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, CSVModelForm, DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
+    StaticSelect2, StaticSelect2Multiple, TagFilterField,
 )
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -36,7 +36,7 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class ClusterTypeCSVForm(forms.ModelForm):
+class ClusterTypeCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta:
@@ -58,7 +58,7 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
-class ClusterGroupCSVForm(forms.ModelForm):
+class ClusterGroupCSVForm(CSVModelForm):
     slug = SlugField()
 
     class Meta: