Sfoglia il codice sorgente

Closes #19735: Implement reuable bulk operations classes (#19774)

* Initial work on #19735

* Work in progress

* Remove ClusterRemoveDevicesView (anti-pattern)

* Misc cleanup

* Fix has_bulk_actions

* Fix has_bulk_actions for ObjectChildrenView

* Restore clone button

* Misc cleanup

* Clean up custom bulk actions

* Rename individual object actions

* Collapse into a single template tag

* Fix support for legacy action dicts

* Rename bulk attr to multi

* clone_button tag should fail silently if view name is invalid

* Clean up action buttons

* Fix export button label

* Replace clone_button with an ObjectAction

* Create object actions for adding device/VM components

* Move core_sync.html to core app

* Remove extra_bulk_buttons from template doc
Jeremy Stretch 7 mesi fa
parent
commit
601a77ac73
56 ha cambiato i file con 567 aggiunte e 973 eliminazioni
  1. 18 0
      netbox/core/object_actions.py
  2. 7 10
      netbox/core/views.py
  3. 38 0
      netbox/dcim/object_actions.py
  4. 29 94
      netbox/dcim/views.py
  5. 8 30
      netbox/extras/views.py
  6. 2 1
      netbox/netbox/constants.py
  7. 176 0
      netbox/netbox/object_actions.py
  8. 6 4
      netbox/netbox/views/generic/bulk_views.py
  9. 36 3
      netbox/netbox/views/generic/mixins.py
  10. 10 3
      netbox/netbox/views/generic/object_views.py
  11. 3 0
      netbox/templates/core/buttons/bulk_sync.html
  12. 0 6
      netbox/templates/core/datafile.html
  13. 0 6
      netbox/templates/core/job.html
  14. 71 0
      netbox/templates/dcim/buttons/bulk_add_components.html
  15. 3 0
      netbox/templates/dcim/buttons/bulk_disconnect.html
  16. 0 22
      netbox/templates/dcim/component_list.html
  17. 0 23
      netbox/templates/dcim/device/components_base.html
  18. 0 28
      netbox/templates/dcim/device/consoleports.html
  19. 0 28
      netbox/templates/dcim/device/consoleserverports.html
  20. 0 14
      netbox/templates/dcim/device/devicebays.html
  21. 0 28
      netbox/templates/dcim/device/frontports.html
  22. 2 27
      netbox/templates/dcim/device/interfaces.html
  23. 0 14
      netbox/templates/dcim/device/inventory.html
  24. 0 14
      netbox/templates/dcim/device/modulebays.html
  25. 0 28
      netbox/templates/dcim/device/poweroutlets.html
  26. 0 28
      netbox/templates/dcim/device/powerports.html
  27. 0 28
      netbox/templates/dcim/device/rearports.html
  28. 0 89
      netbox/templates/dcim/device_list.html
  29. 0 25
      netbox/templates/dcim/devicetype/component_templates.html
  30. 0 30
      netbox/templates/dcim/moduletype/component_templates.html
  31. 0 10
      netbox/templates/dcim/virtualchassis.html
  32. 0 11
      netbox/templates/extras/configcontext_list.html
  33. 0 11
      netbox/templates/extras/configtemplate_list.html
  34. 0 11
      netbox/templates/extras/exporttemplate_list.html
  35. 0 72
      netbox/templates/generic/bulk_remove.html
  36. 1 9
      netbox/templates/generic/object.html
  37. 3 32
      netbox/templates/generic/object_children.html
  38. 3 21
      netbox/templates/generic/object_list.html
  39. 22 0
      netbox/templates/virtualization/buttons/bulk_add_components.html
  40. 0 13
      netbox/templates/virtualization/cluster/devices.html
  41. 0 14
      netbox/templates/virtualization/virtualmachine/interfaces.html
  42. 0 14
      netbox/templates/virtualization/virtualmachine/virtual_disks.html
  43. 0 29
      netbox/templates/virtualization/virtualmachine_list.html
  44. 2 6
      netbox/tenancy/views.py
  45. 3 6
      netbox/utilities/templates/buttons/add.html
  46. 3 6
      netbox/utilities/templates/buttons/bulk_delete.html
  47. 3 6
      netbox/utilities/templates/buttons/bulk_edit.html
  48. 3 0
      netbox/utilities/templates/buttons/bulk_rename.html
  49. 2 2
      netbox/utilities/templates/buttons/delete.html
  50. 1 2
      netbox/utilities/templates/buttons/edit.html
  51. 1 1
      netbox/utilities/templates/buttons/export.html
  52. 3 6
      netbox/utilities/templates/buttons/import.html
  53. 1 2
      netbox/utilities/templates/buttons/sync.html
  54. 74 42
      netbox/utilities/templatetags/buttons.py
  55. 26 0
      netbox/virtualization/object_actions.py
  56. 7 64
      netbox/virtualization/views.py

+ 18 - 0
netbox/core/object_actions.py

@@ -0,0 +1,18 @@
+from django.utils.translation import gettext as _
+
+from netbox.object_actions import ObjectAction
+
+__all__ = (
+    'BulkSync',
+)
+
+
+class BulkSync(ObjectAction):
+    """
+    Synchronize multiple objects at once.
+    """
+    name = 'bulk_sync'
+    label = _('Sync Data')
+    multi = True
+    permissions_required = {'sync'}
+    template_name = 'core/buttons/bulk_sync.html'

+ 7 - 10
netbox/core/views.py

@@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry
 
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
 from netbox.config import get_config, PARAMS
+from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
 from netbox.registry import registry
 from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
@@ -138,14 +139,13 @@ class DataFileListView(generic.ObjectListView):
     filterset = filtersets.DataFileFilterSet
     filterset_form = forms.DataFileFilterForm
     table = tables.DataFileTable
-    actions = {
-        'bulk_delete': {'delete'},
-    }
+    actions = (BulkDelete,)
 
 
 @register_model_view(DataFile)
 class DataFileView(generic.ObjectView):
     queryset = DataFile.objects.all()
+    actions = (DeleteObject,)
 
 
 @register_model_view(DataFile, 'delete')
@@ -170,15 +170,13 @@ class JobListView(generic.ObjectListView):
     filterset = filtersets.JobFilterSet
     filterset_form = forms.JobFilterForm
     table = tables.JobTable
-    actions = {
-        'export': {'view'},
-        'bulk_delete': {'delete'},
-    }
+    actions = (BulkExport, BulkDelete)
 
 
 @register_model_view(Job)
 class JobView(generic.ObjectView):
     queryset = Job.objects.all()
+    actions = (DeleteObject,)
 
 
 @register_model_view(Job, 'delete')
@@ -204,9 +202,7 @@ class ObjectChangeListView(generic.ObjectListView):
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
     template_name = 'core/objectchange_list.html'
-    actions = {
-        'export': {'view'},
-    }
+    actions = (BulkExport,)
 
 
 @register_model_view(ObjectChange)
@@ -274,6 +270,7 @@ class ConfigRevisionListView(generic.ObjectListView):
     filterset = filtersets.ConfigRevisionFilterSet
     filterset_form = forms.ConfigRevisionFilterForm
     table = tables.ConfigRevisionTable
+    actions = (AddObject, BulkExport)
 
 
 @register_model_view(ConfigRevision)

+ 38 - 0
netbox/dcim/object_actions.py

@@ -0,0 +1,38 @@
+from django.utils.translation import gettext as _
+
+from netbox.object_actions import ObjectAction
+
+__all__ = (
+    'BulkAddComponents',
+    'BulkDisconnect',
+)
+
+
+class BulkAddComponents(ObjectAction):
+    """
+    Add components to the selected devices.
+    """
+    label = _('Add Components')
+    multi = True
+    permissions_required = {'change'}
+    template_name = 'dcim/buttons/bulk_add_components.html'
+
+    @classmethod
+    def get_context(cls, context, obj):
+        return {
+            'perms': context.get('perms'),
+            'request': context.get('request'),
+            'formaction': context.get('formaction'),
+            'label': cls.label,
+        }
+
+
+class BulkDisconnect(ObjectAction):
+    """
+    Disconnect each of a set of objects to which a cable is connected.
+    """
+    name = 'bulk_disconnect'
+    label = _('Disconnect Selected')
+    multi = True
+    permissions_required = {'change'}
+    template_name = 'dcim/buttons/bulk_disconnect.html'

+ 29 - 94
netbox/dcim/views.py

@@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
-from netbox.constants import DEFAULT_ACTION_PERMISSIONS
+from netbox.object_actions import *
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -34,6 +34,7 @@ from wireless.models import WirelessLAN
 from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .models import *
+from .object_actions import BulkAddComponents, BulkDisconnect
 
 CABLE_TERMINATION_TYPES = {
     'dcim.consoleport': ConsolePort,
@@ -49,11 +50,6 @@ CABLE_TERMINATION_TYPES = {
 
 
 class DeviceComponentsView(generic.ObjectChildrenView):
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-        'bulk_disconnect': {'change'},
-    }
     queryset = Device.objects.all()
 
     def get_children(self, request, parent):
@@ -61,12 +57,8 @@ class DeviceComponentsView(generic.ObjectChildrenView):
 
 
 class DeviceTypeComponentsView(generic.ObjectChildrenView):
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     queryset = DeviceType.objects.all()
-    template_name = 'dcim/devicetype/component_templates.html'
     viewname = None  # Used for return_url resolution
 
     def get_children(self, request, parent):
@@ -78,9 +70,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
         }
 
 
-class ModuleTypeComponentsView(DeviceComponentsView):
+class ModuleTypeComponentsView(generic.ObjectChildrenView):
     queryset = ModuleType.objects.all()
-    template_name = 'dcim/moduletype/component_templates.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     viewname = None  # Used for return_url resolution
 
     def get_children(self, request, parent):
@@ -2116,7 +2108,7 @@ class DeviceListView(generic.ObjectListView):
     filterset = filtersets.DeviceFilterSet
     filterset_form = forms.DeviceFilterForm
     table = tables.DeviceTable
-    template_name = 'dcim/device_list.html'
+    actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
 
 
 @register_model_view(Device)
@@ -2157,7 +2149,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
-    template_name = 'dcim/device/consoleports.html',
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     tab = ViewTab(
         label=_('Console Ports'),
         badge=lambda obj: obj.console_port_count,
@@ -2173,7 +2165,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
     table = tables.DeviceConsoleServerPortTable
     filterset = filtersets.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
-    template_name = 'dcim/device/consoleserverports.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     tab = ViewTab(
         label=_('Console Server Ports'),
         badge=lambda obj: obj.console_server_port_count,
@@ -2189,7 +2181,7 @@ class DevicePowerPortsView(DeviceComponentsView):
     table = tables.DevicePowerPortTable
     filterset = filtersets.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
-    template_name = 'dcim/device/powerports.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     tab = ViewTab(
         label=_('Power Ports'),
         badge=lambda obj: obj.power_port_count,
@@ -2205,7 +2197,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
     table = tables.DevicePowerOutletTable
     filterset = filtersets.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
-    template_name = 'dcim/device/poweroutlets.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     tab = ViewTab(
         label=_('Power Outlets'),
         badge=lambda obj: obj.power_outlet_count,
@@ -2221,6 +2213,7 @@ class DeviceInterfacesView(DeviceComponentsView):
     table = tables.DeviceInterfaceTable
     filterset = filtersets.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     template_name = 'dcim/device/interfaces.html'
     tab = ViewTab(
         label=_('Interfaces'),
@@ -2243,7 +2236,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
     table = tables.DeviceFrontPortTable
     filterset = filtersets.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
-    template_name = 'dcim/device/frontports.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     tab = ViewTab(
         label=_('Front Ports'),
         badge=lambda obj: obj.front_port_count,
@@ -2259,7 +2252,7 @@ class DeviceRearPortsView(DeviceComponentsView):
     table = tables.DeviceRearPortTable
     filterset = filtersets.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
-    template_name = 'dcim/device/rearports.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
     tab = ViewTab(
         label=_('Rear Ports'),
         badge=lambda obj: obj.rear_port_count,
@@ -2275,11 +2268,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
     filterset_form = forms.ModuleBayFilterForm
-    template_name = 'dcim/device/modulebays.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     tab = ViewTab(
         label=_('Module Bays'),
         badge=lambda obj: obj.module_bay_count,
@@ -2295,11 +2284,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
-    template_name = 'dcim/device/devicebays.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     tab = ViewTab(
         label=_('Device Bays'),
         badge=lambda obj: obj.device_bay_count,
@@ -2315,11 +2300,7 @@ class DeviceInventoryView(DeviceComponentsView):
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
-    template_name = 'dcim/device/inventory.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     tab = ViewTab(
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventory_item_count,
@@ -2472,11 +2453,7 @@ class ConsolePortListView(generic.ObjectListView):
     filterset = filtersets.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(ConsolePort)
@@ -2547,11 +2524,7 @@ class ConsoleServerPortListView(generic.ObjectListView):
     filterset = filtersets.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(ConsoleServerPort)
@@ -2622,11 +2595,7 @@ class PowerPortListView(generic.ObjectListView):
     filterset = filtersets.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(PowerPort)
@@ -2697,11 +2666,7 @@ class PowerOutletListView(generic.ObjectListView):
     filterset = filtersets.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(PowerOutlet)
@@ -2772,11 +2737,7 @@ class InterfaceListView(generic.ObjectListView):
     filterset = filtersets.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(Interface)
@@ -2920,11 +2881,7 @@ class FrontPortListView(generic.ObjectListView):
     filterset = filtersets.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(FrontPort)
@@ -2995,11 +2952,7 @@ class RearPortListView(generic.ObjectListView):
     filterset = filtersets.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(RearPort)
@@ -3070,11 +3023,7 @@ class ModuleBayListView(generic.ObjectListView):
     filterset = filtersets.ModuleBayFilterSet
     filterset_form = forms.ModuleBayFilterForm
     table = tables.ModuleBayTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(ModuleBay)
@@ -3136,11 +3085,7 @@ class DeviceBayListView(generic.ObjectListView):
     filterset = filtersets.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(DeviceBay)
@@ -3283,11 +3228,7 @@ class InventoryItemListView(generic.ObjectListView):
     filterset = filtersets.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
-    template_name = 'dcim/component_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
 
 
 @register_model_view(InventoryItem)
@@ -3627,9 +3568,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
     filterset_form = forms.ConsoleConnectionFilterForm
     table = tables.ConsoleConnectionTable
     template_name = 'dcim/connections_list.html'
-    actions = {
-        'export': {'view'},
-    }
+    actions = (BulkExport,)
 
     def get_extra_context(self, request):
         return {
@@ -3643,9 +3582,7 @@ class PowerConnectionsListView(generic.ObjectListView):
     filterset_form = forms.PowerConnectionFilterForm
     table = tables.PowerConnectionTable
     template_name = 'dcim/connections_list.html'
-    actions = {
-        'export': {'view'},
-    }
+    actions = (BulkExport,)
 
     def get_extra_context(self, request):
         return {
@@ -3659,9 +3596,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
     filterset_form = forms.InterfaceConnectionFilterForm
     table = tables.InterfaceConnectionTable
     template_name = 'dcim/connections_list.html'
-    actions = {
-        'export': {'view'},
-    }
+    actions = (BulkExport,)
 
     def get_extra_context(self, request):
         return {

+ 8 - 30
netbox/extras/views.py

@@ -14,12 +14,13 @@ from jinja2.exceptions import TemplateError
 
 from core.choices import ManagedFileRootPathChoices
 from core.models import Job
+from core.object_actions import BulkSync
 from dcim.models import Device, DeviceRole, Platform
 from extras.choices import LogLevelChoices
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.utils import SharedObjectViewMixin
-from netbox.constants import DEFAULT_ACTION_PERMISSIONS
+from netbox.object_actions import *
 from netbox.views import generic
 from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm, get_field_value
@@ -232,11 +233,7 @@ class ExportTemplateListView(generic.ObjectListView):
     filterset = filtersets.ExportTemplateFilterSet
     filterset_form = forms.ExportTemplateFilterForm
     table = tables.ExportTemplateTable
-    template_name = 'extras/exporttemplate_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_sync': {'sync'},
-    }
+    actions = (AddObject, BulkImport, BulkSync, BulkEdit, BulkExport, BulkDelete)
 
 
 @register_model_view(ExportTemplate)
@@ -347,9 +344,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
     filterset = filtersets.TableConfigFilterSet
     filterset_form = forms.TableConfigFilterForm
     table = tables.TableConfigTable
-    actions = {
-        'export': {'view'},
-    }
+    actions = (BulkExport,)
 
 
 @register_model_view(TableConfig)
@@ -758,13 +753,7 @@ class ConfigContextListView(generic.ObjectListView):
     filterset = filtersets.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     table = tables.ConfigContextTable
-    template_name = 'extras/configcontext_list.html'
-    actions = {
-        'add': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-        'bulk_sync': {'sync'},
-    }
+    actions = (AddObject, BulkSync, BulkEdit, BulkDelete)
 
 
 @register_model_view(ConfigContext)
@@ -877,11 +866,7 @@ class ConfigTemplateListView(generic.ObjectListView):
     filterset = filtersets.ConfigTemplateFilterSet
     filterset_form = forms.ConfigTemplateFilterForm
     table = tables.ConfigTemplateTable
-    template_name = 'extras/configtemplate_list.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_sync': {'sync'},
-    }
+    actions = (AddObject, BulkImport, BulkSync, BulkEdit, BulkExport, BulkDelete)
 
 
 @register_model_view(ConfigTemplate)
@@ -992,9 +977,7 @@ class ImageAttachmentListView(generic.ObjectListView):
     filterset = filtersets.ImageAttachmentFilterSet
     filterset_form = forms.ImageAttachmentFilterForm
     table = tables.ImageAttachmentTable
-    actions = {
-        'export': {'view'},
-    }
+    actions = (BulkExport,)
 
 
 @register_model_view(ImageAttachment, 'add', detail=False)
@@ -1038,12 +1021,7 @@ class JournalEntryListView(generic.ObjectListView):
     filterset = filtersets.JournalEntryFilterSet
     filterset_form = forms.JournalEntryFilterForm
     table = tables.JournalEntryTable
-    actions = {
-        'export': {'view'},
-        'bulk_import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-    }
+    actions = (BulkImport, BulkEdit, BulkDelete)
 
 
 @register_model_view(JournalEntry)

+ 2 - 1
netbox/netbox/constants.py

@@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = {
     'job-schedules': 110100,
 }
 
-# Default view action permission mapping
+# TODO: Remove in NetBox v4.6
+# Legacy default view action permission mapping
 DEFAULT_ACTION_PERMISSIONS = {
     'add': {'add'},
     'export': {'view'},

+ 176 - 0
netbox/netbox/object_actions.py

@@ -0,0 +1,176 @@
+from django.urls import reverse
+from django.utils.translation import gettext as _
+
+from core.models import ObjectType
+from extras.models import ExportTemplate
+from utilities.querydict import prepare_cloned_fields
+
+__all__ = (
+    'AddObject',
+    'BulkDelete',
+    'BulkEdit',
+    'BulkExport',
+    'BulkImport',
+    'BulkRename',
+    'CloneObject',
+    'DeleteObject',
+    'EditObject',
+    'ObjectAction',
+)
+
+
+class ObjectAction:
+    """
+    Base class for single- and multi-object operations.
+
+    Params:
+        name: The action name appended to the module for view resolution
+        label: Human-friendly label for the rendered button
+        multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table)
+        permissions_required: The set of permissions a user must have to perform the action
+        url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL
+    """
+    name = ''
+    label = None
+    multi = False
+    permissions_required = set()
+    url_kwargs = []
+
+    @classmethod
+    def get_url(cls, obj):
+        viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}'
+        kwargs = {
+            kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
+        }
+        return reverse(viewname, kwargs=kwargs)
+
+    @classmethod
+    def get_context(cls, context, obj):
+        return {
+            'url': cls.get_url(obj),
+            'label': cls.label,
+        }
+
+
+class AddObject(ObjectAction):
+    """
+    Create a new object.
+    """
+    name = 'add'
+    label = _('Add')
+    permissions_required = {'add'}
+    template_name = 'buttons/add.html'
+
+
+class CloneObject(ObjectAction):
+    """
+    Populate the new object form with select details from an existing object.
+    """
+    name = 'add'
+    label = _('Clone')
+    permissions_required = {'add'}
+    template_name = 'buttons/clone.html'
+
+    @classmethod
+    def get_context(cls, context, obj):
+        param_string = prepare_cloned_fields(obj).urlencode()
+        url = f'{cls.get_url(obj)}?{param_string}' if param_string else None
+        return {
+            'url': url,
+            'label': cls.label,
+        }
+
+
+class EditObject(ObjectAction):
+    """
+    Edit a single object.
+    """
+    name = 'edit'
+    label = _('Edit')
+    permissions_required = {'change'}
+    url_kwargs = ['pk']
+    template_name = 'buttons/edit.html'
+
+
+class DeleteObject(ObjectAction):
+    """
+    Delete a single object.
+    """
+    name = 'delete'
+    label = _('Delete')
+    permissions_required = {'delete'}
+    url_kwargs = ['pk']
+    template_name = 'buttons/delete.html'
+
+
+class BulkImport(ObjectAction):
+    """
+    Import multiple objects at once.
+    """
+    name = 'bulk_import'
+    label = _('Import')
+    permissions_required = {'add'}
+    template_name = 'buttons/import.html'
+
+
+class BulkExport(ObjectAction):
+    """
+    Export multiple objects at once.
+    """
+    name = 'export'
+    label = _('Export')
+    permissions_required = {'view'}
+    template_name = 'buttons/export.html'
+
+    @classmethod
+    def get_context(cls, context, model):
+        object_type = ObjectType.objects.get_for_model(model)
+        user = context['request'].user
+
+        # Determine if the "all data" export returns CSV or YAML
+        data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
+
+        # Retrieve all export templates for this model
+        export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
+
+        return {
+            'label': cls.label,
+            'perms': context['perms'],
+            'object_type': object_type,
+            'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
+            'export_templates': export_templates,
+            'data_format': data_format,
+        }
+
+
+class BulkEdit(ObjectAction):
+    """
+    Change the value of one or more fields on a set of objects.
+    """
+    name = 'bulk_edit'
+    label = _('Edit Selected')
+    multi = True
+    permissions_required = {'change'}
+    template_name = 'buttons/bulk_edit.html'
+
+
+class BulkRename(ObjectAction):
+    """
+    Rename multiple objects at once.
+    """
+    name = 'bulk_rename'
+    label = _('Rename Selected')
+    multi = True
+    permissions_required = {'change'}
+    template_name = 'buttons/bulk_rename.html'
+
+
+class BulkDelete(ObjectAction):
+    """
+    Delete each of a set of objects.
+    """
+    name = 'bulk_delete'
+    label = _('Delete Selected')
+    multi = True
+    permissions_required = {'delete'}
+    template_name = 'buttons/bulk_delete.html'

+ 6 - 4
netbox/netbox/views/generic/bulk_views.py

@@ -22,6 +22,7 @@ from core.models import ObjectType
 from core.signals import clear_events
 from extras.choices import CustomFieldUIEditableChoices
 from extras.models import CustomField, ExportTemplate
+from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
@@ -60,6 +61,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
     template_name = 'generic/object_list.html'
     filterset = None
     filterset_form = None
+    actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
@@ -150,13 +152,13 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
         # Determine the available actions
         actions = self.get_permitted_actions(request.user)
-        has_bulk_actions = any([a.startswith('bulk_') for a in actions])
+        has_table_actions = any(action.multi for action in actions)
 
         if 'export' in request.GET:
 
             # Export the current table view
             if request.GET['export'] == 'table':
-                table = self.get_table(self.queryset, request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_table_actions)
                 columns = [name for name, _ in table.selected_columns]
                 return self.export_table(table, columns)
 
@@ -174,11 +176,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
             # Fall back to default table/YAML export
             else:
-                table = self.get_table(self.queryset, request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_table_actions)
                 return self.export_table(table)
 
         # Render the objects table
-        table = self.get_table(self.queryset, request, has_bulk_actions)
+        table = self.get_table(self.queryset, request, has_table_actions)
 
         # If this is an HTMX request, return only the rendered table HTML
         if htmx_partial(request):

+ 36 - 3
netbox/netbox/views/generic/mixins.py

@@ -1,7 +1,7 @@
 from django.shortcuts import get_object_or_404
 
 from extras.models import TableConfig
-from netbox.constants import DEFAULT_ACTION_PERMISSIONS
+from netbox import object_actions
 from utilities.permissions import get_permission_for_model
 
 __all__ = (
@@ -9,6 +9,18 @@ __all__ = (
     'TableMixin',
 )
 
+# TODO: Remove in NetBox v4.5
+LEGACY_ACTIONS = {
+    'add': object_actions.AddObject,
+    'edit': object_actions.EditObject,
+    'delete': object_actions.DeleteObject,
+    'export': object_actions.BulkExport,
+    'bulk_import': object_actions.BulkImport,
+    'bulk_edit': object_actions.BulkEdit,
+    'bulk_rename': object_actions.BulkRename,
+    'bulk_delete': object_actions.BulkDelete,
+}
+
 
 class ActionsMixin:
     """
@@ -19,7 +31,24 @@ class ActionsMixin:
     Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
     with custom actions, such as bulk_sync.
     """
-    actions = DEFAULT_ACTION_PERMISSIONS
+    actions = tuple()
+
+    # TODO: Remove in NetBox v4.5
+    def _convert_legacy_actions(self):
+        """
+        Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses.
+        """
+        if type(self.actions) is not dict:
+            return
+
+        actions = []
+        for name in self.actions.keys():
+            try:
+                actions.append(LEGACY_ACTIONS[name])
+            except KeyError:
+                raise ValueError(f"Unsupported legacy action: {name}")
+
+        self.actions = actions
 
     def get_permitted_actions(self, user, model=None):
         """
@@ -27,11 +56,15 @@ class ActionsMixin:
         """
         model = model or self.queryset.model
 
+        # TODO: Remove in NetBox v4.5
+        # Handle legacy action sets
+        self._convert_legacy_actions()
+
         # Resolve required permissions for each action
         permitted_actions = []
         for action in self.actions:
             required_permissions = [
-                get_permission_for_model(model, name) for name in self.actions.get(action, set())
+                get_permission_for_model(model, perm) for perm in action.permissions_required
             ]
             if not required_permissions or user.has_perms(required_permissions):
                 permitted_actions.append(action)

+ 10 - 3
netbox/netbox/views/generic/object_views.py

@@ -14,6 +14,9 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 
 from core.signals import clear_events
+from netbox.object_actions import (
+    AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject,
+)
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import ConfirmationForm, restrict_form_fields
@@ -36,7 +39,7 @@ __all__ = (
 )
 
 
-class ObjectView(BaseObjectView):
+class ObjectView(ActionsMixin, BaseObjectView):
     """
     Retrieve a single object for display.
 
@@ -46,6 +49,7 @@ class ObjectView(BaseObjectView):
         tab: A ViewTab instance for the view
     """
     tab = None
+    actions = (CloneObject, EditObject, DeleteObject)
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
@@ -72,9 +76,11 @@ class ObjectView(BaseObjectView):
             request: The current request
         """
         instance = self.get_object(**kwargs)
+        actions = self.get_permitted_actions(request.user, model=instance)
 
         return render(request, self.get_template_name(), {
             'object': instance,
+            'actions': actions,
             'tab': self.tab,
             **self.get_extra_context(request, instance),
         })
@@ -97,6 +103,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
     table = None
     filterset = None
     filterset_form = None
+    actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
     template_name = 'generic/object_children.html'
 
     def get_children(self, request, parent):
@@ -138,10 +145,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
 
         # Determine the available actions
         actions = self.get_permitted_actions(request.user, model=self.child_model)
-        has_bulk_actions = any([a.startswith('bulk_') for a in actions])
+        has_table_actions = any(action.multi for action in actions)
 
         table_data = self.prep_table_data(request, child_objects, instance)
-        table = self.get_table(table_data, request, has_bulk_actions)
+        table = self.get_table(table_data, request, has_table_actions)
 
         # If this is an HTMX request, return only the rendered table HTML
         if htmx_partial(request):

+ 3 - 0
netbox/templates/core/buttons/bulk_sync.html

@@ -0,0 +1,3 @@
+<button type="submit" name="_sync" {% formaction %}="{{ url }}" class="btn btn-primary">
+  <i class="mdi mdi-sync" aria-hidden="true"></i> {{ label }}
+</button>

+ 0 - 6
netbox/templates/core/datafile.html

@@ -11,12 +11,6 @@
   <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
 {% endblock %}
 
-{% block control-buttons %}
-  {% if request.user|can_delete:object %}
-    {% delete_button object %}
-  {% endif %}
-{% endblock control-buttons %}
-
 {% block content %}
   <div class="row mb-3">
     <div class="col">

+ 0 - 6
netbox/templates/core/job.html

@@ -22,12 +22,6 @@
   {% endif %}
 {% endblock breadcrumbs %}
 
-{% block control-buttons %}
-  {% if request.user|can_delete:object %}
-    {% delete_button object %}
-  {% endif %}
-{% endblock control-buttons %}
-
 {% block content %}
   <div class="row mb-3">
     <div class="col col-12 col-md-6">

+ 71 - 0
netbox/templates/dcim/buttons/bulk_add_components.html

@@ -0,0 +1,71 @@
+{% load i18n %}
+<div class="btn-group">
+  <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {{ label }}
+  </button>
+  <ul class="dropdown-menu">
+    {% if perms.dcim.add_consoleport %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+         {% trans "Console Ports" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_consoleserverport %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
+          {% trans "Console Server Ports" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_powerport %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Power Ports" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_poweroutlet %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Power Outlets" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_interface %}
+      <li>
+          <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+            {% trans "Interfaces" %}
+          </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_rearport %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Rear Ports" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_devicebay %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Device Bays" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_modulebay %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Module Bays" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_inventoryitem %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Inventory Items" %}
+        </button>
+      </li>
+    {% endif %}
+  </ul>
+</div>

+ 3 - 0
netbox/templates/dcim/buttons/bulk_disconnect.html

@@ -0,0 +1,3 @@
+<button type="submit" name="_disconnect" {% formaction %}="{{ url }}" class="btn btn-red">
+  <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {{ label }}
+</button>

+ 0 - 22
netbox/templates/dcim/component_list.html

@@ -1,22 +0,0 @@
-{% extends 'generic/object_list.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_buttons %}
-  <div class="btn-group" role="group">
-    {% if 'bulk_edit' in actions %}
-      {% bulk_edit_button model query_params=request.GET %}
-    {% endif %}
-    {% if 'bulk_rename' in actions %}
-      {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
-        <button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-float">
-          <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
-        </button>
-      {% endwith %}
-    {% endif %}
-  </div>
-  {% if 'bulk_delete' in actions %}
-    {% bulk_delete_button model query_params=request.GET %}
-  {% endif %}
-{% endblock %}

+ 0 - 23
netbox/templates/dcim/device/components_base.html

@@ -1,23 +0,0 @@
-{% extends 'generic/object_children.html' %}
-{% load helpers %}
-
-{% block bulk_edit_controls %}
-    {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
-        {% if 'bulk_edit' in actions and bulk_edit_view %}
-            <button type="submit" name="_edit"
-                    {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
-                    class="btn btn-warning">
-                <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
-            </button>
-        {% endif %}
-    {% endwith %}
-    {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
-        {% if 'bulk_rename' in actions and bulk_rename_view %}
-            <button type="submit" name="_rename"
-                    {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-warning">
-                <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_edit_controls %}

+ 0 - 28
netbox/templates/dcim/device/consoleports.html

@@ -1,28 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_consoleport %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Ports" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 28
netbox/templates/dcim/device/consoleserverports.html

@@ -1,28 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_consoleserverport %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 14
netbox/templates/dcim/device/devicebays.html

@@ -1,14 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load i18n %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_devicebay %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 28
netbox/templates/dcim/device/frontports.html

@@ -1,28 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_frontport %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Front Ports" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 2 - 27
netbox/templates/dcim/device/interfaces.html

@@ -1,30 +1,5 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
+{% extends 'generic/object_children.html' %}
 
 {% block table_controls %}
-    {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
+  {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
 {% endblock table_controls %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_interface %}
-        <a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
-           class="btn btn-primary">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
-        </a>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 14
netbox/templates/dcim/device/inventory.html

@@ -1,14 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load i18n %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_inventoryitem %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 14
netbox/templates/dcim/device/modulebays.html

@@ -1,14 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load i18n %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_modulebay %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 28
netbox/templates/dcim/device/poweroutlets.html

@@ -1,28 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_poweroutlet %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 28
netbox/templates/dcim/device/powerports.html

@@ -1,28 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_powerport %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 28
netbox/templates/dcim/device/rearports.html

@@ -1,28 +0,0 @@
-{% extends 'dcim/device/components_base.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
-        {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
-            <button type="submit" name="_disconnect"
-                    {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-danger">
-                <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_delete_controls %}
-
-{% block bulk_extra_controls %}
-    {{ block.super }}
-    {% if perms.dcim.add_rearport %}
-        <div class="btn-group" role="group">
-            <a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}"
-               class="btn btn-primary">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Rear Ports" %}
-            </a>
-        </div>
-    {% endif %}
-{% endblock bulk_extra_controls %}

+ 0 - 89
netbox/templates/dcim/device_list.html

@@ -1,89 +0,0 @@
-{% extends 'generic/object_list.html' %}
-{% load buttons %}
-{% load i18n %}
-
-{% block bulk_buttons %}
-  {% if perms.dcim.change_device %}
-    <div class="dropdown">
-      <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
-      </button>
-      <ul class="dropdown-menu">
-        {% if perms.dcim.add_consoleport %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-             {% trans "Console Ports" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_consoleserverport %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
-              {% trans "Console Server Ports" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_powerport %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Power Ports" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_poweroutlet %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Power Outlets" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_interface %}
-          <li>
-              <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-                {% trans "Interfaces" %}
-              </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_rearport %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Rear Ports" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_devicebay %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Device Bays" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_modulebay %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Module Bays" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.dcim.add_inventoryitem %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Inventory Items" %}
-            </button>
-          </li>
-        {% endif %}
-      </ul>
-    </div>
-  {% endif %}
-  {% if 'bulk_edit' in actions %}
-    <div class="btn-group" role="group">
-      {% bulk_edit_button model query_params=request.GET %}
-      <button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning btn-float">
-        <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-      </button>
-    </div>
-  {% endif %}
-  {% if 'bulk_delete' in actions %}
-    {% bulk_delete_button model query_params=request.GET %}
-  {% endif %}
-{% endblock %}

+ 0 - 25
netbox/templates/dcim/devicetype/component_templates.html

@@ -1,25 +0,0 @@
-{% extends 'generic/object_children.html' %}
-{% load helpers %}
-{% load i18n %}
-{% load perms %}
-
-{% block bulk_edit_controls %}
-    {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
-        {% if 'bulk_edit' in actions and bulk_edit_view %}
-            <button type="submit" name="_edit"
-                    {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
-                    class="btn btn-warning">
-                <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
-            </button>
-        {% endif %}
-    {% endwith %}
-    {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
-        {% if 'bulk_rename' in actions and bulk_rename_view %}
-            <button type="submit" name="_rename"
-                    {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-warning">
-                <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_edit_controls %}

+ 0 - 30
netbox/templates/dcim/moduletype/component_templates.html

@@ -1,30 +0,0 @@
-{% extends 'generic/object_children.html' %}
-{% load render_table from django_tables2 %}
-{% load helpers %}
-{% load i18n %}
-
-{% block extra_controls %}
-  {%  include 'dcim/inc/moduletype_buttons.html' %}
-{% endblock %}
-
-{% block bulk_edit_controls %}
-    {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
-        {% if 'bulk_edit' in actions and bulk_edit_view %}
-            <button type="submit" name="_edit"
-                    {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
-                    class="btn btn-warning">
-                <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
-            </button>
-        {% endif %}
-    {% endwith %}
-    {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
-        {% if 'bulk_rename' in actions and bulk_rename_view %}
-            <button type="submit" name="_rename"
-                    {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
-                    class="btn btn-outline-warning">
-                <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
-            </button>
-        {% endif %}
-    {% endwith %}
-{% endblock bulk_edit_controls %}
-

+ 0 - 10
netbox/templates/dcim/virtualchassis.html

@@ -1,18 +1,8 @@
 {% extends 'generic/object.html' %}
-{% load buttons %}
 {% load helpers %}
 {% load plugins %}
 {% load i18n %}
 
-{% block buttons %}
-  {% if perms.dcim.change_virtualchassis %}
-    {% edit_button object %}
-  {% endif %}
-  {% if perms.dcim.delete_virtualchassis %}
-    {% delete_button object %}
-  {% endif %}
-{% endblock %}
-
 {% block content %}
 <div class="row">
 	<div class="col col-12 col-md-4">

+ 0 - 11
netbox/templates/extras/configcontext_list.html

@@ -1,11 +0,0 @@
-{% extends 'generic/object_list.html' %}
-{% load i18n %}
-
-{% block bulk_buttons %}
-  {% if perms.extras.sync_configcontext %}
-    <button type="submit" name="_sync" {% formaction %}="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary">
-      <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
-    </button>
-  {% endif %}
-  {{ block.super }}
-{% endblock %}

+ 0 - 11
netbox/templates/extras/configtemplate_list.html

@@ -1,11 +0,0 @@
-{% extends 'generic/object_list.html' %}
-{% load i18n %}
-
-{% block bulk_buttons %}
-  {% if perms.extras.sync_configtemplate %}
-    <button type="submit" name="_sync" {% formaction %}="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary">
-      <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
-    </button>
-  {% endif %}
-  {{ block.super }}
-{% endblock %}

+ 0 - 11
netbox/templates/extras/exporttemplate_list.html

@@ -1,11 +0,0 @@
-{% extends 'generic/object_list.html' %}
-{% load i18n %}
-
-{% block bulk_buttons %}
-  {% if perms.extras.sync_configcontext %}
-    <button type="submit" name="_sync" {% formaction %}="{% url 'extras:exporttemplate_bulk_sync' %}" class="btn btn-primary">
-      <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
-    </button>
-  {% endif %}
-  {{ block.super }}
-{% endblock %}

+ 0 - 72
netbox/templates/generic/bulk_remove.html

@@ -1,72 +0,0 @@
-{% extends 'generic/_base.html' %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
-
-{% comment %}
-Blocks:
-  - title:    Page title
-  - tabs:     Page tabs
-  - content:  Primary page content
-
-Context:
-  - form:              The bulk edit form class
-  - parent_obj:        The parent object
-  - table:             A table of objects being removed
-  - obj_type_plural:   The plural form of the object type
-  - return_url:        The URL to which the user is redirected after submitting the form
-{% endcomment %}
-
-{% block title %}
-  {% trans "Remove" %} {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?
-{% endblock %}
-
-{% block tabs %}
-  <ul class="nav nav-tabs">
-    <li class="nav-item" role="presentation">
-      <button class="nav-link active" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
-        {% trans "Bulk Remove" %}
-      </button>
-    </li>
-  </ul>
-{% endblock tabs %}
-
-{% block content %}
-  <div class="tab-pane show active" role="tabpanel">
-    <div class="alert alert-danger bg-danger-subtle" role="alert">
-      <div class="d-flex">
-        <div>
-          <i class="mdi mdi-alert-octagon p-2"></i>
-        </div>
-        <div>
-          <h4 class="alert-title">{% trans "Confirm Bulk Removal" %}</h4>
-          {% blocktrans trimmed with count=table.rows|length %}
-            The following operation will remove {{ count }} {{ obj_type_plural }} from {{ parent_obj }}. Please
-            carefully review the {{ obj_type_plural }} to be removed and confirm below.
-          {% endblocktrans %}
-        </div>
-      </div>
-    </div>
-    <div class="container-fluid px-0">
-      <div class="card">
-        <div class="table-responsive">
-          {% render_table table 'inc/table.html' %}
-        </div>
-      </div>
-      <form action="." method="post" class="form">
-        {% csrf_token %}
-        {% for field in form.hidden_fields %}
-          {{ field }}
-        {% endfor %}
-        <div class="text-end">
-          <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
-          <button type="submit" name="_confirm" class="btn btn-danger">
-            {% blocktrans trimmed with count=table.rows|length %}
-              Remove these {{ count }} {{ obj_type_plural }}
-            {% endblocktrans %}
-          </button>
-        </div>
-      </form>
-    </div>
-  </div>
-{% endblock content %}

+ 1 - 9
netbox/templates/generic/object.html

@@ -80,15 +80,7 @@ Context:
       {% if perms.extras.add_subscription and object.subscriptions %}
         {% subscribe_button object %}
       {% endif %}
-      {% if request.user|can_add:object %}
-        {% clone_button object %}
-      {% endif %}
-      {% if request.user|can_change:object %}
-        {% edit_button object %}
-      {% endif %}
-      {% if request.user|can_delete:object %}
-        {% delete_button object %}
-      {% endif %}
+      {% action_buttons actions object %}
     {% endblock control-buttons %}
   </div>
 

+ 3 - 32
netbox/templates/generic/object_children.html

@@ -1,4 +1,5 @@
 {% extends base_template %}
+{% load buttons %}
 {% load helpers %}
 {% load i18n %}
 
@@ -7,8 +8,6 @@ Blocks:
   - content:                   Primary page content
     - table_controls:          Control elements for the child objects table
     - bulk_controls:           Bulk action buttons which appear beneath the child objects table
-      - bulk_edit_controls:    Bulk edit buttons
-      - bulk_delete_controls:  Bulk delete buttons
       - bulk_extra_controls:   Other bulk action buttons
   - modals:                    Any pre-loaded modals
 
@@ -36,36 +35,8 @@ Context:
         </div>
         <div class="d-print-none mt-2">
             {% block bulk_controls %}
-                <div class="btn-group" role="group">
-                    {# Bulk edit buttons #}
-                    {% block bulk_edit_controls %}
-                        {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
-                            {% if 'bulk_edit' in actions and bulk_edit_view %}
-                                <button type="submit" name="_edit"
-                                        {% formaction %}="{% url bulk_edit_view %}?return_url={{ return_url }}"
-                                        class="btn btn-warning">
-                                    <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
-                                </button>
-                            {% endif %}
-                        {% endwith %}
-                    {% endblock bulk_edit_controls %}
-                </div>
-                <div class="btn-group" role="group">
-                    {# Bulk delete buttons #}
-                    {% block bulk_delete_controls %}
-                        {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}
-                            {% if 'bulk_delete' in actions and bulk_delete_view %}
-                                <button type="submit"
-                                        {% formaction %}="{% url bulk_delete_view %}?return_url={{ return_url }}"
-                                        class="btn btn-danger">
-                                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
-                                </button>
-                            {% endif %}
-                        {% endwith %}
-                    {% endblock bulk_delete_controls %}
-                </div>
-                {# Other bulk action buttons #}
-                {% block bulk_extra_controls %}{% endblock %}
+              {% action_buttons actions model multi=True %}
+              {% block bulk_extra_controls %}{% endblock %}
             {% endblock bulk_controls %}
         </div>
     </form>

+ 3 - 21
netbox/templates/generic/object_list.html

@@ -31,15 +31,7 @@ Context:
   <div class="btn-list">
     {% plugin_list_buttons model %}
     {% block extra_controls %}{% endblock %}
-    {% if 'add' in actions %}
-      {% add_button model %}
-    {% endif %}
-    {% if 'bulk_import' in actions %}
-      {% import_button model %}
-    {% endif %}
-    {% if 'export' in actions %}
-      {% export_button model %}
-    {% endif %}
+    {% action_buttons actions model %}
   </div>
 {% endblock controls %}
 
@@ -91,12 +83,7 @@ Context:
                   </label>
                 </div>
                 <div class="bulk-action-buttons">
-                  {% if 'bulk_edit' in actions %}
-                    {% bulk_edit_button model query_params=request.GET %}
-                  {% endif %}
-                  {% if 'bulk_delete' in actions %}
-                    {% bulk_delete_button model query_params=request.GET %}
-                  {% endif %}
+                  {% action_buttons actions model multi=True %}
                 </div>
               </div>
             </div>
@@ -124,12 +111,7 @@ Context:
           <div class="btn-list d-print-none">
             {% block bulk_buttons %}
               <div class="bulk-action-buttons">
-                {% if 'bulk_edit' in actions %}
-                  {% bulk_edit_button model query_params=request.GET %}
-                {% endif %}
-                {% if 'bulk_delete' in actions %}
-                  {% bulk_delete_button model query_params=request.GET %}
-                {% endif %}
+                {% action_buttons actions model multi=True %}
               </div>
             {% endblock %}
           </div>

+ 22 - 0
netbox/templates/virtualization/buttons/bulk_add_components.html

@@ -0,0 +1,22 @@
+{% load i18n %}
+<div class="btn-group">
+  <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
+  </button>
+  <ul class="dropdown-menu">
+    {% if perms.virtualization.add_vminterface %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Interfaces" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.virtualization.add_virtualdisk %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Virtual Disks" %}
+        </button>
+      </li>
+    {% endif %}
+  </ul>
+</div>

+ 0 - 13
netbox/templates/virtualization/cluster/devices.html

@@ -1,13 +0,0 @@
-{% extends 'generic/object_children.html' %}
-{% load i18n %}
-
-{% block bulk_delete_controls %}
-    {{ block.super }}
-    {% if 'bulk_remove_devices' in actions %}
-        <button type="submit" name="_remove"
-                {% formaction %}="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}"
-                class="btn btn-danger">
-            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Selected" %}
-        </button>
-    {% endif %}
-{% endblock bulk_delete_controls %}

+ 0 - 14
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -1,14 +0,0 @@
-{% extends 'generic/object_children.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_edit_controls %}
-    {{ block.super }}
-    {% if 'bulk_rename' in actions %}
-        <button type="submit" name="_rename"
-                {% formaction %}="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}"
-                class="btn btn-outline-warning">
-            <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-        </button>
-    {% endif %}
-{% endblock bulk_edit_controls %}

+ 0 - 14
netbox/templates/virtualization/virtualmachine/virtual_disks.html

@@ -1,14 +0,0 @@
-{% extends 'generic/object_children.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block bulk_edit_controls %}
-    {{ block.super }}
-    {% if 'bulk_rename' in actions %}
-        <button type="submit" name="_rename"
-                {% formaction %}="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
-                class="btn btn-outline-warning">
-            <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
-        </button>
-    {% endif %}
-{% endblock bulk_edit_controls %}

+ 0 - 29
netbox/templates/virtualization/virtualmachine_list.html

@@ -1,29 +0,0 @@
-{% extends 'generic/object_list.html' %}
-{% load i18n %}
-
-{% block bulk_buttons %}
-  {% if perms.virtualization.change_virtualmachine %}
-    <div class="dropdown">
-      <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
-      </button>
-      <ul class="dropdown-menu">
-        {% if perms.virtualization.add_vminterface %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Interfaces" %}
-            </button>
-          </li>
-        {% endif %}
-        {% if perms.virtualization.add_virtualdisk %}
-          <li>
-            <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
-              {% trans "Virtual Disks" %}
-            </button>
-          </li>
-        {% endif %}
-      </ul>
-    </div>
-  {% endif %}
-  {{ block.super }}
-{% endblock %}

+ 2 - 6
netbox/tenancy/views.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.shortcuts import get_object_or_404
 
+from netbox.object_actions import BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.views import GetRelatedModelsMixin, register_model_view
@@ -349,12 +350,7 @@ class ContactAssignmentListView(generic.ObjectListView):
     filterset = filtersets.ContactAssignmentFilterSet
     filterset_form = forms.ContactAssignmentFilterForm
     table = tables.ContactAssignmentTable
-    actions = {
-        'export': {'view'},
-        'bulk_import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-    }
+    actions = (BulkExport, BulkImport, BulkEdit, BulkDelete)
 
 
 @register_model_view(ContactAssignment, 'add', detail=False)

+ 3 - 6
netbox/utilities/templates/buttons/add.html

@@ -1,6 +1,3 @@
-{% if url %}
-{% load i18n %}
-  <a href="{{ url }}" type="button" class="btn btn-primary">
-    <i class="mdi mdi-plus-thick"></i> {% trans "Add" %}
-  </a>
-{% endif %}
+<a href="{{ url }}" class="btn btn-primary" role="button">
+  <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {{  label }}
+</a>

+ 3 - 6
netbox/utilities/templates/buttons/bulk_delete.html

@@ -1,6 +1,3 @@
-{% load i18n %}
-{% if url %}
-  <button type="submit" name="_delete" {% formaction %}="{{ url }}" class="btn btn-red">
-    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
-  </button>
-{% endif %}
+<button type="submit" name="_delete" {% formaction %}="{{ url }}" class="btn btn-red">
+  <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {{ label }}
+</button>

+ 3 - 6
netbox/utilities/templates/buttons/bulk_edit.html

@@ -1,6 +1,3 @@
-{% load i18n %}
-{% if url %}
-  <button type="submit" name="_edit" {% formaction %}="{{ url }}" class="btn btn-yellow">
-    <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
-  </button>
-{% endif %}
+<button type="submit" name="_edit" {% formaction %}="{{ url }}" class="btn btn-yellow">
+  <i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
+</button>

+ 3 - 0
netbox/utilities/templates/buttons/bulk_rename.html

@@ -0,0 +1,3 @@
+<button type="submit" name="_rename" {% formaction %}="{{ url }}" class="btn btn-yellow">
+  <i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
+</button>

+ 2 - 2
netbox/utilities/templates/buttons/delete.html

@@ -1,12 +1,12 @@
-{% load i18n %}
 <a href="#"
   hx-get="{{ url }}"
   hx-target="#htmx-modal-content"
   hx-swap="innerHTML"
   hx-select="form"
   class="btn btn-red"
+  role="button"
   data-bs-toggle="modal"
   data-bs-target="#htmx-modal"
 >
-  <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
+  <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {{ label }}
 </a>

+ 1 - 2
netbox/utilities/templates/buttons/edit.html

@@ -1,4 +1,3 @@
-{% load i18n %}
 <a href="{{ url }}" class="btn btn-yellow" role="button">
-  <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+  <i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
 </a>

+ 1 - 1
netbox/utilities/templates/buttons/export.html

@@ -1,7 +1,7 @@
 {% load i18n %}
 <div class="dropdown">
   <button type="button" class="btn btn-purple dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-    <i class="mdi mdi-download"></i> {% trans "Export" %}
+    <i class="mdi mdi-download" aria-hidden="true"></i> {{ label }}
   </button>
   <ul class="dropdown-menu dropdown-menu-end">
     <li><a id="export_current_view" class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">{% trans "Current View" %}</a></li>

+ 3 - 6
netbox/utilities/templates/buttons/import.html

@@ -1,6 +1,3 @@
-{% load i18n %}
-{% if url %}
-  <a href="{{ url }}" type="button" class="btn btn-cyan">
-    <i class="mdi mdi-upload"></i> {% trans "Import" %}
-  </a>
-{% endif %}
+<a href="{{ url }}" class="btn btn-cyan" role="button">
+  <i class="mdi mdi-upload" aria-hidden="true"></i> {{ label }}
+</a>

+ 1 - 2
netbox/utilities/templates/buttons/sync.html

@@ -1,7 +1,6 @@
-{% load i18n %}
 <form action="{{ url }}" method="post">
   {% csrf_token %}
   <button type="submit" class="btn btn-primary">
-    <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync" %}
+    <i class="mdi mdi-sync" aria-hidden="true"></i> {{ label }}
   </button>
 </form>

+ 74 - 42
netbox/utilities/templatetags/buttons.py

@@ -1,6 +1,9 @@
 from django import template
 from django.contrib.contenttypes.models import ContentType
+from django.template import loader
 from django.urls import NoReverseMatch, reverse
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 
 from core.models import ObjectType
 from extras.models import Bookmark, ExportTemplate, Subscription
@@ -9,6 +12,7 @@ from utilities.querydict import prepare_cloned_fields
 from utilities.views import get_viewname
 
 __all__ = (
+    'action_buttons',
     'add_button',
     'bookmark_button',
     'bulk_delete_button',
@@ -25,9 +29,14 @@ __all__ = (
 register = template.Library()
 
 
-#
-# Instance buttons
-#
+@register.simple_tag(takes_context=True)
+def action_buttons(context, actions, obj, multi=False):
+    buttons = [
+        loader.render_to_string(action.template_name, action.get_context(context, obj))
+        for action in actions if action.multi == multi
+    ]
+    return mark_safe(''.join(buttons))
+
 
 @register.inclusion_tag('buttons/bookmark.html', takes_context=True)
 def bookmark_button(context, instance):
@@ -60,42 +69,6 @@ def bookmark_button(context, instance):
     }
 
 
-@register.inclusion_tag('buttons/clone.html')
-def clone_button(instance):
-    url = reverse(get_viewname(instance, 'add'))
-
-    # Populate cloned field values
-    param_string = prepare_cloned_fields(instance).urlencode()
-    if param_string:
-        url = f'{url}?{param_string}'
-    else:
-        url = None
-
-    return {
-        'url': url,
-    }
-
-
-@register.inclusion_tag('buttons/edit.html')
-def edit_button(instance):
-    viewname = get_viewname(instance, 'edit')
-    url = reverse(viewname, kwargs={'pk': instance.pk})
-
-    return {
-        'url': url,
-    }
-
-
-@register.inclusion_tag('buttons/delete.html')
-def delete_button(instance):
-    viewname = get_viewname(instance, 'delete')
-    url = reverse(viewname, kwargs={'pk': instance.pk})
-
-    return {
-        'url': url,
-    }
-
-
 @register.inclusion_tag('buttons/subscribe.html', takes_context=True)
 def subscribe_button(context, instance):
     # Skip for objects which don't support notifications
@@ -131,20 +104,70 @@ def subscribe_button(context, instance):
     }
 
 
+#
+# Legacy object buttons
+#
+
+# TODO: Remove in NetBox v4.6
+@register.inclusion_tag('buttons/clone.html')
+def clone_button(instance):
+    # Resolve URL path
+    viewname = get_viewname(instance, 'add')
+    try:
+        url = reverse(viewname)
+    except NoReverseMatch:
+        return {
+            'url': None,
+        }
+
+    # Populate cloned field values and return full URL
+    param_string = prepare_cloned_fields(instance).urlencode()
+    return {
+        'url': f'{url}?{param_string}' if param_string else None,
+    }
+
+
+# TODO: Remove in NetBox v4.6
+@register.inclusion_tag('buttons/edit.html')
+def edit_button(instance):
+    viewname = get_viewname(instance, 'edit')
+    url = reverse(viewname, kwargs={'pk': instance.pk})
+
+    return {
+        'url': url,
+        'label': _('Edit'),
+    }
+
+
+# TODO: Remove in NetBox v4.6
+@register.inclusion_tag('buttons/delete.html')
+def delete_button(instance):
+    viewname = get_viewname(instance, 'delete')
+    url = reverse(viewname, kwargs={'pk': instance.pk})
+
+    return {
+        'url': url,
+        'label': _('Delete'),
+    }
+
+
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/sync.html')
 def sync_button(instance):
     viewname = get_viewname(instance, 'sync')
     url = reverse(viewname, kwargs={'pk': instance.pk})
 
     return {
+        'label': _('Sync'),
         'url': url,
     }
 
 
 #
-# List buttons
+# Legacy list buttons
 #
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/add.html')
 def add_button(model, action='add'):
     try:
@@ -154,9 +177,11 @@ def add_button(model, action='add'):
 
     return {
         'url': url,
+        'label': _('Add'),
     }
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/import.html')
 def import_button(model, action='bulk_import'):
     try:
@@ -166,9 +191,11 @@ def import_button(model, action='bulk_import'):
 
     return {
         'url': url,
+        'label': _('Import'),
     }
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/export.html', takes_context=True)
 def export_button(context, model):
     object_type = ObjectType.objects.get_for_model(model)
@@ -181,6 +208,7 @@ def export_button(context, model):
     export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
 
     return {
+        'label': _('Export'),
         'perms': context['perms'],
         'object_type': object_type,
         'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
@@ -189,6 +217,7 @@ def export_button(context, model):
     }
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/bulk_edit.html', takes_context=True)
 def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
     try:
@@ -199,11 +228,13 @@ def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
         url = None
 
     return {
-        'htmx_navigation': context.get('htmx_navigation'),
+        'label': _('Edit Selected'),
         'url': url,
+        'htmx_navigation': context.get('htmx_navigation'),
     }
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/bulk_delete.html', takes_context=True)
 def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
     try:
@@ -214,6 +245,7 @@ def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
         url = None
 
     return {
-        'htmx_navigation': context.get('htmx_navigation'),
+        'label': _('Delete Selected'),
         'url': url,
+        'htmx_navigation': context.get('htmx_navigation'),
     }

+ 26 - 0
netbox/virtualization/object_actions.py

@@ -0,0 +1,26 @@
+from django.utils.translation import gettext as _
+
+from netbox.object_actions import ObjectAction
+
+__all__ = (
+    'BulkAddComponents',
+)
+
+
+class BulkAddComponents(ObjectAction):
+    """
+    Add components to the selected virtual machines.
+    """
+    label = _('Add Components')
+    multi = True
+    permissions_required = {'change'}
+    template_name = 'virtualization/buttons/bulk_add_components.html'
+
+    @classmethod
+    def get_context(cls, context, obj):
+        return {
+            'perms': context.get('perms'),
+            'request': context.get('request'),
+            'formaction': context.get('formaction'),
+            'label': cls.label,
+        }

+ 7 - 64
netbox/virtualization/views.py

@@ -13,13 +13,14 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
-from netbox.constants import DEFAULT_ACTION_PERMISSIONS
+from netbox.object_actions import *
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from . import filtersets, forms, tables
 from .models import *
+from .object_actions import BulkAddComponents
 
 
 #
@@ -204,6 +205,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit)
     tab = ViewTab(
         label=_('Virtual Machines'),
         badge=lambda obj: obj.virtual_machines.count(),
@@ -222,14 +224,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     table = DeviceTable
     filterset = DeviceFilterSet
     filterset_form = DeviceFilterForm
-    template_name = 'virtualization/cluster/devices.html'
-    actions = {
-        'add': {'add'},
-        'export': {'view'},
-        'bulk_import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_remove_devices': {'change'},
-    }
+    actions = (EditObject, DeleteObject, BulkEdit)
     tab = ViewTab(
         label=_('Devices'),
         badge=lambda obj: obj.devices.count(),
@@ -317,50 +312,6 @@ class ClusterAddDevicesView(generic.ObjectEditView):
         })
 
 
-@register_model_view(Cluster, 'remove_devices', path='devices/remove')
-class ClusterRemoveDevicesView(generic.ObjectEditView):
-    queryset = Cluster.objects.all()
-    form = forms.ClusterRemoveDevicesForm
-    template_name = 'generic/bulk_remove.html'
-
-    def post(self, request, pk):
-
-        cluster = get_object_or_404(self.queryset, pk=pk)
-
-        if '_confirm' in request.POST:
-            form = self.form(request.POST)
-            if form.is_valid():
-
-                device_pks = form.cleaned_data['pk']
-                with transaction.atomic(using=router.db_for_write(Device)):
-
-                    # Remove the selected Devices from the Cluster
-                    for device in Device.objects.filter(pk__in=device_pks):
-                        device.cluster = None
-                        device.save()
-
-                messages.success(request, _("Removed {count} devices from cluster {cluster}").format(
-                    count=len(device_pks),
-                    cluster=cluster
-                ))
-                return redirect(cluster.get_absolute_url())
-
-        else:
-            form = self.form(initial={'pk': request.POST.getlist('pk')})
-
-        selected_objects = Device.objects.filter(pk__in=form.initial['pk'])
-        device_table = DeviceTable(list(selected_objects), orderable=False)
-        device_table.configure(request)
-
-        return render(request, self.template_name, {
-            'form': form,
-            'parent_obj': cluster,
-            'table': device_table,
-            'obj_type_plural': 'devices',
-            'return_url': cluster.get_absolute_url(),
-        })
-
-
 #
 # Virtual machines
 #
@@ -371,7 +322,7 @@ class VirtualMachineListView(generic.ObjectListView):
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
     table = tables.VirtualMachineTable
-    template_name = 'virtualization/virtualmachine_list.html'
+    actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkDelete)
 
 
 @register_model_view(VirtualMachine)
@@ -386,11 +337,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
     filterset_form = forms.VMInterfaceFilterForm
-    template_name = 'virtualization/virtualmachine/interfaces.html'
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     tab = ViewTab(
         label=_('Interfaces'),
         badge=lambda obj: obj.interface_count,
@@ -412,17 +359,13 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
     table = tables.VirtualMachineVirtualDiskTable
     filterset = filtersets.VirtualDiskFilterSet
     filterset_form = forms.VirtualDiskFilterForm
-    template_name = 'virtualization/virtualmachine/virtual_disks.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     tab = ViewTab(
         label=_('Virtual Disks'),
         badge=lambda obj: obj.virtual_disk_count,
         permission='virtualization.view_virtualdisk',
         weight=500
     )
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
 
     def get_children(self, request, parent):
         return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags')