From 839e999a7103173a957dc77e7caf1111e3d1bcfe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 5 May 2020 16:15:09 -0400 Subject: [PATCH] Introduce CSVModelForm for dynamic CSV imports --- netbox/circuits/forms.py | 4 +- netbox/dcim/forms.py | 329 ++++++++++++++++---------------- netbox/dcim/models/__init__.py | 10 +- netbox/dcim/tests/test_views.py | 8 +- netbox/extras/forms.py | 4 +- netbox/ipam/forms.py | 168 ++++++++-------- netbox/ipam/models.py | 6 +- netbox/secrets/forms.py | 6 +- netbox/tenancy/forms.py | 8 +- netbox/utilities/forms.py | 14 ++ netbox/utilities/views.py | 7 +- netbox/virtualization/forms.py | 10 +- 12 files changed, 280 insertions(+), 294 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 05d34e351..50504d99f 100644 --- a/netbox/circuits/forms.py +++ b/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: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 94561f9a0..c84a3bb28 100644 --- a/netbox/dcim/forms.py +++ b/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): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - 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)) - - # 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) - ) - - # 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): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - manufacturer = self.cleaned_data.get('manufacturer') - model_name = self.cleaned_data.get('model_name') - - # 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): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - parent = self.cleaned_data.get('parent') - device_bay_name = self.cleaned_data.get('device_bay_name') - - # 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): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - rack_group_name = self.cleaned_data.get('rack_group_name') - - # 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): diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index cb7c14fe2..ad30ce2e2 100644 --- a/netbox/dcim/models/__init__.py +++ b/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 = [ diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b1aaf4449..e8a58893b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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", diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 676d7ceba..384b3563b 100644 --- a/netbox/extras/forms.py +++ b/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): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 7bed22866..2f2c99ed7 100644 --- a/netbox/ipam/forms.py +++ b/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) - 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): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 3035c271b..84720845e 100644 --- a/netbox/ipam/models.py +++ b/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', ] diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index afc9708b1..ec21a48ab 100644 --- a/netbox/secrets/forms.py +++ b/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: diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 5f803e816..fca8e9924 100644 --- a/netbox/tenancy/forms.py +++ b/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(), diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index c347669ec..98761252d 100644 --- a/netbox/utilities/forms.py +++ b/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 diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 964d9490c..ec6776c02 100644 --- a/netbox/utilities/views.py +++ b/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) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 894783433..f3c5d1633 100644 --- a/netbox/virtualization/forms.py +++ b/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: