From ba28507bc0cdd43463e12131270f822b86f0f921 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Sat, 23 Nov 2019 00:42:09 +0100 Subject: [PATCH] Implement CSV import for netbox-community#822 --- netbox/dcim/forms.py | 206 +++++++++++++++++++++++++++++++++++++++++- netbox/dcim/tables.py | 46 ++++++++++ netbox/dcim/urls.py | 5 + netbox/dcim/views.py | 35 +++++++ 4 files changed, 291 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 66c98c022..5fad96d0f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -25,7 +25,7 @@ from utilities.forms import ( ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES ) -from virtualization.models import Cluster, ClusterGroup +from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .constants import * from .models import ( Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, @@ -2057,6 +2057,26 @@ class PowerPortCreateForm(ComponentForm): ) +class PowerPortCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + + class Meta: + model = PowerPort + fields = PowerPort.csv_headers + + def clean_type(self): + # The type may be blank, but not None + value = self.cleaned_data['type'] + return value if value is not None else '' + + # # Power outlets # @@ -2117,6 +2137,56 @@ class PowerOutletCreateForm(ComponentForm): self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent) +class PowerOutletCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + power_port = FlexibleModelChoiceField( + queryset=PowerPort.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of Power Port', + error_messages={ + 'invalid_choice': 'Power Port not found.', + } + ) + feed_leg = CSVChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + ) + + class Meta: + model = PowerOutlet + fields = PowerOutlet.csv_headers + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit PowerPort choices to those belonging to this device (or VC master) + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + try: + device = self.instance.device + except Device.DoesNotExist: + device = None + + if device: + self.fields['power_port'].queryset = PowerPort.objects.filter( + device__in=[device, device.get_vc_master()] + ) + else: + self.fields['power_port'].queryset = PowerPort.objects.none() + + class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), @@ -2364,6 +2434,73 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): self.fields['tagged_vlans'].choices = vlan_choices +class InterfaceCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + virtual_machine = FlexibleModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of virtual machine', + error_messages={ + 'invalid_choice': 'Virtual machine not found.', + } + ) + lag = FlexibleModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of LAG interface', + error_messages={ + 'invalid_choice': 'LAG interface not found.', + } + ) + type = CSVChoiceField( + choices=IFACE_TYPE_CHOICES, + ) + mode = CSVChoiceField( + choices=IFACE_MODE_CHOICES, + required=False, + ) + + class Meta: + model = Interface + fields = Interface.csv_headers + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit LAG choices to interfaces belonging to this device (or VC master) + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + device = self.instance.device + + if device: + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.none() + + def clean_enabled(self): + # Make sure enabled is True when it's not included in the uploaded data + if 'enabled' in self.data: + return True + else: + return self.cleaned_data['enabled'] + + class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), @@ -2580,6 +2717,55 @@ class FrontPortCreateForm(ComponentForm): } +class FrontPortCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + rear_port = FlexibleModelChoiceField( + queryset=RearPort.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of Rear Port', + error_messages={ + 'invalid_choice': 'Rear Port not found.', + } + ) + type = CSVChoiceField( + choices=PORT_TYPE_CHOICES, + ) + + class Meta: + model = FrontPort + fields = FrontPort.csv_headers + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit RearPort choices to those belonging to this device (or VC master) + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + try: + device = self.instance.device + except Device.DoesNotExist: + device = None + + if device: + self.fields['rear_port'].queryset = RearPort.objects.filter( + device__in=[device, device.get_vc_master()] + ) + else: + self.fields['rear_port'].queryset = RearPort.objects.none() + + class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -2654,6 +2840,24 @@ class RearPortCreateForm(ComponentForm): ) +class RearPortCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + type = CSVChoiceField( + choices=PORT_TYPE_CHOICES, + ) + + class Meta: + model = RearPort + fields = RearPort.csv_headers + + class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 70a9aa5c8..fedd7e7e0 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -454,6 +454,15 @@ class PowerPortTemplateTable(BaseTable): empty_text = "None" +class PowerPortImportTable(BaseTable): + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + + class Meta(BaseTable.Meta): + model = PowerPort + fields = ('device', 'name', 'description', 'maximum_draw', 'allocated_draw') + empty_text = False + + class PowerOutletTemplateTable(BaseTable): pk = ToggleColumn() actions = tables.TemplateColumn( @@ -468,6 +477,15 @@ class PowerOutletTemplateTable(BaseTable): empty_text = "None" +class PowerOutletImportTable(BaseTable): + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + + class Meta(BaseTable.Meta): + model = PowerOutlet + fields = ('device', 'name', 'description', 'power_port', 'feed_leg') + empty_text = False + + class InterfaceTemplateTable(BaseTable): pk = ToggleColumn() mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}") @@ -483,6 +501,16 @@ class InterfaceTemplateTable(BaseTable): empty_text = "None" +class InterfaceImportTable(BaseTable): + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + virtual_machine = tables.LinkColumn('virtualization:virtualmachine', args=[Accessor('virtual_machine.pk')], verbose_name='Virtual Machine') + + class Meta(BaseTable.Meta): + model = Interface + fields = ('device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode') + empty_text = False + + class FrontPortTemplateTable(BaseTable): pk = ToggleColumn() rear_port_position = tables.Column( @@ -500,6 +528,15 @@ class FrontPortTemplateTable(BaseTable): empty_text = "None" +class FrontPortImportTable(BaseTable): + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + + class Meta(BaseTable.Meta): + model = FrontPort + fields = ('device', 'name', 'description', 'type', 'rear_port', 'rear_port_position') + empty_text = False + + class RearPortTemplateTable(BaseTable): pk = ToggleColumn() actions = tables.TemplateColumn( @@ -514,6 +551,15 @@ class RearPortTemplateTable(BaseTable): empty_text = "None" +class RearPortImportTable(BaseTable): + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + + class Meta(BaseTable.Meta): + model = RearPort + fields = ('device', 'name', 'description', 'type', 'position') + empty_text = False + + class DeviceBayTemplateTable(BaseTable): pk = ToggleColumn() actions = tables.TemplateColumn( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c3e852d1e..52ca46758 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -196,6 +196,7 @@ urlpatterns = [ path(r'power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path(r'power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path(r'power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), # Power outlets path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), @@ -208,6 +209,7 @@ urlpatterns = [ path(r'power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), + path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'), # Interfaces path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), @@ -222,6 +224,7 @@ urlpatterns = [ path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'), # Front ports # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), @@ -234,6 +237,7 @@ urlpatterns = [ path(r'front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'), # Rear ports # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), @@ -246,6 +250,7 @@ urlpatterns = [ path(r'rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), + path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'), # Device bays path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 959e1043e..2f09c27f5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1283,6 +1283,13 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = PowerPort +class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_powerport' + model_form = forms.PowerPortCSVForm + table = tables.PowerPortImportTable + default_return_url = 'dcim:powerport_list' + + class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' queryset = PowerPort.objects.all() @@ -1315,6 +1322,13 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = PowerOutlet +class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_poweroutlet' + model_form = forms.PowerOutletCSVForm + table = tables.PowerOutletImportTable + default_return_url = 'dcim:poweroutlet_list' + + class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_poweroutlet' queryset = PowerOutlet.objects.all() @@ -1404,6 +1418,13 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = Interface +class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_interface' + model_form = forms.InterfaceCSVForm + table = tables.InterfaceImportTable + default_return_url = 'dcim:interface_list' + + class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' queryset = Interface.objects.all() @@ -1456,6 +1477,13 @@ class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = FrontPort +class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_frontport' + model_form = forms.FrontPortCSVForm + table = tables.FrontPortImportTable + default_return_url = 'dcim:frontport_list' + + class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_frontport' queryset = FrontPort.objects.all() @@ -1508,6 +1536,13 @@ class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = RearPort +class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_rearport' + model_form = forms.RearPortCSVForm + table = tables.RearPortImportTable + default_return_url = 'dcim:rearport_list' + + class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rearport' queryset = RearPort.objects.all()