Repository: netbox-community/netbox

Issue:
Enable bulk edit and delete for all device components
### Environment
* Python version: 3.6.9
* NetBox version: 2.7.4

### Proposed Functionality
Implement the ability to edit or delete device components in bulk under their respective list views. The tables currently render with checkboxes, however no actions are available to the user.

### Use Case
This allows a user to conveniently apply changes or delete components in bulk.

### Database Changes
None

### External Dependencies
None


Discussion:

Resolving patch:
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -2371,6 +2371,27 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
     )
 
 
+class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsolePort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(ConsolePortTypeChoices),
+        required=False,
+        widget=StaticSelect2()
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = (
+            'description',
+        )
+
+
 class ConsolePortCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
@@ -2544,6 +2565,37 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
     )
 
 
+class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(PowerPortTypeChoices),
+        required=False,
+        widget=StaticSelect2()
+    )
+    maximum_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Maximum draw in watts"
+    )
+    allocated_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Allocated draw in watts"
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = (
+            'description',
+        )
+
+
 class PowerPortCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
@@ -2695,6 +2747,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     )
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
+        required=False,
         widget=forms.HiddenInput()
     )
     type = forms.ChoiceField(
@@ -2726,6 +2779,9 @@ def __init__(self, *args, **kwargs):
         if 'device' in self.initial:
             device = Device.objects.filter(pk=self.initial['device']).first()
             self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
+        else:
+            self.fields['power_port'].choices = ()
+            self.fields['power_port'].widget.attrs['disabled'] = True
 
 
 class PowerOutletBulkRenameForm(BulkRenameForm):
@@ -2969,6 +3025,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     )
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
+        required=False,
         widget=forms.HiddenInput()
     )
     type = forms.ChoiceField(
@@ -3044,6 +3101,9 @@ def __init__(self, *args, **kwargs):
                 device__in=[device, device.get_vc_master()],
                 type=InterfaceTypeChoices.TYPE_LAG
             )
+        else:
+            self.fields['lag'].choices = ()
+            self.fields['lag'].widget.attrs['disabled'] = True
 
     def clean(self):
 
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -178,7 +178,8 @@
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
-    # TODO: Bulk edit, rename, disconnect views for ConsolePorts
+    path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
+    # TODO: Bulk rename, disconnect views for ConsolePorts
     path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
@@ -204,7 +205,8 @@
     path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
     path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
     path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
-    # TODO: Bulk edit, rename, disconnect views for PowerPorts
+    path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
+    # TODO: Bulk rename, disconnect views for PowerPorts
     path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1224,7 +1224,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/consoleport_list.html'
 
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1253,6 +1253,13 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
     default_return_url = 'dcim:consoleport_list'
 
 
+class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleport'
+    queryset = ConsolePort.objects.all()
+    table = tables.ConsolePortTable
+    form = forms.ConsolePortBulkEditForm
+
+
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleport'
     queryset = ConsolePort.objects.all()
@@ -1270,7 +1277,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/consoleserverport_list.html'
 
 
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1335,7 +1342,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/powerport_list.html'
 
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1364,6 +1371,13 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
     default_return_url = 'dcim:powerport_list'
 
 
+class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_powerport'
+    queryset = PowerPort.objects.all()
+    table = tables.PowerPortTable
+    form = forms.PowerPortBulkEditForm
+
+
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerport'
     queryset = PowerPort.objects.all()
@@ -1381,7 +1395,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/poweroutlet_list.html'
 
 
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1446,7 +1460,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/interface_list.html'
 
 
 class InterfaceView(PermissionRequiredMixin, View):
@@ -1548,7 +1562,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/frontport_list.html'
 
 
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1613,7 +1627,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/rearport_list.html'
 
 
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1680,7 +1694,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/devicebay_list.html'
 
 
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):


Test patch:
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1070,7 +1070,6 @@ class ConsolePortTestCase(StandardTestCases.Views):
     # Disable inapplicable views
     test_get_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
 
     def test_bulk_create_objects(self):
         return self._test_bulk_create_objects(expected_count=3)
@@ -1101,6 +1100,11 @@ def setUpTestData(cls):
             'tags': 'Alpha,Bravo,Charlie',
         }
 
+        cls.bulk_edit_data = {
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'New description',
+        }
+
         cls.csv_data = (
             "device,name",
             "Device 1,Console Port 4",
@@ -1164,7 +1168,6 @@ class PowerPortTestCase(StandardTestCases.Views):
 
     # Disable inapplicable views
     test_get_object = None
-    test_bulk_edit_objects = None
     test_create_object = None
 
     def test_bulk_create_objects(self):
@@ -1200,6 +1203,13 @@ def setUpTestData(cls):
             'tags': 'Alpha,Bravo,Charlie',
         }
 
+        cls.bulk_edit_data = {
+            'type': PowerPortTypeChoices.TYPE_IEC_C14,
+            'maximum_draw': 100,
+            'allocated_draw': 50,
+            'description': 'New description',
+        }
+
         cls.csv_data = (
             "device,name",
             "Device 1,Power Port 4",