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

+ 8 - 30
netbox/extras/views.py

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

+ 2 - 1
netbox/netbox/constants.py

@@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = {
     'job-schedules': 110100,
     'job-schedules': 110100,
 }
 }
 
 
-# Default view action permission mapping
+# TODO: Remove in NetBox v4.6
+# Legacy default view action permission mapping
 DEFAULT_ACTION_PERMISSIONS = {
 DEFAULT_ACTION_PERMISSIONS = {
     'add': {'add'},
     'add': {'add'},
     'export': {'view'},
     '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 core.signals import clear_events
 from extras.choices import CustomFieldUIEditableChoices
 from extras.choices import CustomFieldUIEditableChoices
 from extras.models import CustomField, ExportTemplate
 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.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
 from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
@@ -60,6 +61,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
     template_name = 'generic/object_list.html'
     template_name = 'generic/object_list.html'
     filterset = None
     filterset = None
     filterset_form = None
     filterset_form = None
+    actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
         return get_permission_for_model(self.queryset.model, 'view')
@@ -150,13 +152,13 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
 
         # Determine the available actions
         # Determine the available actions
         actions = self.get_permitted_actions(request.user)
         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:
         if 'export' in request.GET:
 
 
             # Export the current table view
             # Export the current table view
             if request.GET['export'] == 'table':
             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]
                 columns = [name for name, _ in table.selected_columns]
                 return self.export_table(table, columns)
                 return self.export_table(table, columns)
 
 
@@ -174,11 +176,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
 
             # Fall back to default table/YAML export
             # Fall back to default table/YAML export
             else:
             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)
                 return self.export_table(table)
 
 
         # Render the objects 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 this is an HTMX request, return only the rendered table HTML
         if htmx_partial(request):
         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 django.shortcuts import get_object_or_404
 
 
 from extras.models import TableConfig
 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
 from utilities.permissions import get_permission_for_model
 
 
 __all__ = (
 __all__ = (
@@ -9,6 +9,18 @@ __all__ = (
     'TableMixin',
     '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:
 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
     Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
     with custom actions, such as bulk_sync.
     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):
     def get_permitted_actions(self, user, model=None):
         """
         """
@@ -27,11 +56,15 @@ class ActionsMixin:
         """
         """
         model = model or self.queryset.model
         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
         # Resolve required permissions for each action
         permitted_actions = []
         permitted_actions = []
         for action in self.actions:
         for action in self.actions:
             required_permissions = [
             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):
             if not required_permissions or user.has_perms(required_permissions):
                 permitted_actions.append(action)
                 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 django.utils.translation import gettext as _
 
 
 from core.signals import clear_events
 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.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import ConfirmationForm, restrict_form_fields
 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.
     Retrieve a single object for display.
 
 
@@ -46,6 +49,7 @@ class ObjectView(BaseObjectView):
         tab: A ViewTab instance for the view
         tab: A ViewTab instance for the view
     """
     """
     tab = None
     tab = None
+    actions = (CloneObject, EditObject, DeleteObject)
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
         return get_permission_for_model(self.queryset.model, 'view')
@@ -72,9 +76,11 @@ class ObjectView(BaseObjectView):
             request: The current request
             request: The current request
         """
         """
         instance = self.get_object(**kwargs)
         instance = self.get_object(**kwargs)
+        actions = self.get_permitted_actions(request.user, model=instance)
 
 
         return render(request, self.get_template_name(), {
         return render(request, self.get_template_name(), {
             'object': instance,
             'object': instance,
+            'actions': actions,
             'tab': self.tab,
             'tab': self.tab,
             **self.get_extra_context(request, instance),
             **self.get_extra_context(request, instance),
         })
         })
@@ -97,6 +103,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
     table = None
     table = None
     filterset = None
     filterset = None
     filterset_form = None
     filterset_form = None
+    actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
     template_name = 'generic/object_children.html'
     template_name = 'generic/object_children.html'
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
@@ -138,10 +145,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
 
 
         # Determine the available actions
         # Determine the available actions
         actions = self.get_permitted_actions(request.user, model=self.child_model)
         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_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 this is an HTMX request, return only the rendered table HTML
         if htmx_partial(request):
         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>
   <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
 {% endblock %}
 {% endblock %}
 
 
-{% block control-buttons %}
-  {% if request.user|can_delete:object %}
-    {% delete_button object %}
-  {% endif %}
-{% endblock control-buttons %}
-
 {% block content %}
 {% block content %}
   <div class="row mb-3">
   <div class="row mb-3">
     <div class="col">
     <div class="col">

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

@@ -22,12 +22,6 @@
   {% endif %}
   {% endif %}
 {% endblock breadcrumbs %}
 {% endblock breadcrumbs %}
 
 
-{% block control-buttons %}
-  {% if request.user|can_delete:object %}
-    {% delete_button object %}
-  {% endif %}
-{% endblock control-buttons %}
-
 {% block content %}
 {% block content %}
   <div class="row mb-3">
   <div class="row mb-3">
     <div class="col col-12 col-md-6">
     <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 %}
 {% 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 %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 {% load plugins %}
 {% load plugins %}
 {% load i18n %}
 {% 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 %}
 {% block content %}
 <div class="row">
 <div class="row">
 	<div class="col col-12 col-md-4">
 	<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 %}
       {% if perms.extras.add_subscription and object.subscriptions %}
         {% subscribe_button object %}
         {% subscribe_button object %}
       {% endif %}
       {% 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 %}
     {% endblock control-buttons %}
   </div>
   </div>
 
 

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

@@ -1,4 +1,5 @@
 {% extends base_template %}
 {% extends base_template %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 {% load i18n %}
 {% load i18n %}
 
 
@@ -7,8 +8,6 @@ Blocks:
   - content:                   Primary page content
   - content:                   Primary page content
     - table_controls:          Control elements for the child objects table
     - table_controls:          Control elements for the child objects table
     - bulk_controls:           Bulk action buttons which appear beneath 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
       - bulk_extra_controls:   Other bulk action buttons
   - modals:                    Any pre-loaded modals
   - modals:                    Any pre-loaded modals
 
 
@@ -36,36 +35,8 @@ Context:
         </div>
         </div>
         <div class="d-print-none mt-2">
         <div class="d-print-none mt-2">
             {% block bulk_controls %}
             {% 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 %}
             {% endblock bulk_controls %}
         </div>
         </div>
     </form>
     </form>

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

@@ -31,15 +31,7 @@ Context:
   <div class="btn-list">
   <div class="btn-list">
     {% plugin_list_buttons model %}
     {% plugin_list_buttons model %}
     {% block extra_controls %}{% endblock %}
     {% 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>
   </div>
 {% endblock controls %}
 {% endblock controls %}
 
 
@@ -91,12 +83,7 @@ Context:
                   </label>
                   </label>
                 </div>
                 </div>
                 <div class="bulk-action-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>
                 </div>
               </div>
               </div>
             </div>
             </div>
@@ -124,12 +111,7 @@ Context:
           <div class="btn-list d-print-none">
           <div class="btn-list d-print-none">
             {% block bulk_buttons %}
             {% block bulk_buttons %}
               <div class="bulk-action-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>
               </div>
             {% endblock %}
             {% endblock %}
           </div>
           </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.contrib.contenttypes.models import ContentType
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 
 
+from netbox.object_actions import BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.views import GetRelatedModelsMixin, register_model_view
 from utilities.views import GetRelatedModelsMixin, register_model_view
@@ -349,12 +350,7 @@ class ContactAssignmentListView(generic.ObjectListView):
     filterset = filtersets.ContactAssignmentFilterSet
     filterset = filtersets.ContactAssignmentFilterSet
     filterset_form = forms.ContactAssignmentFilterForm
     filterset_form = forms.ContactAssignmentFilterForm
     table = tables.ContactAssignmentTable
     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)
 @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="#"
 <a href="#"
   hx-get="{{ url }}"
   hx-get="{{ url }}"
   hx-target="#htmx-modal-content"
   hx-target="#htmx-modal-content"
   hx-swap="innerHTML"
   hx-swap="innerHTML"
   hx-select="form"
   hx-select="form"
   class="btn btn-red"
   class="btn btn-red"
+  role="button"
   data-bs-toggle="modal"
   data-bs-toggle="modal"
   data-bs-target="#htmx-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>
 </a>

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

@@ -1,4 +1,3 @@
-{% load i18n %}
 <a href="{{ url }}" class="btn btn-yellow" role="button">
 <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>
 </a>

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

@@ -1,7 +1,7 @@
 {% load i18n %}
 {% load i18n %}
 <div class="dropdown">
 <div class="dropdown">
   <button type="button" class="btn btn-purple dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
   <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>
   </button>
   <ul class="dropdown-menu dropdown-menu-end">
   <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>
     <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">
 <form action="{{ url }}" method="post">
   {% csrf_token %}
   {% csrf_token %}
   <button type="submit" class="btn btn-primary">
   <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>
   </button>
 </form>
 </form>

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

@@ -1,6 +1,9 @@
 from django import template
 from django import template
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.template import loader
 from django.urls import NoReverseMatch, reverse
 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 core.models import ObjectType
 from extras.models import Bookmark, ExportTemplate, Subscription
 from extras.models import Bookmark, ExportTemplate, Subscription
@@ -9,6 +12,7 @@ from utilities.querydict import prepare_cloned_fields
 from utilities.views import get_viewname
 from utilities.views import get_viewname
 
 
 __all__ = (
 __all__ = (
+    'action_buttons',
     'add_button',
     'add_button',
     'bookmark_button',
     'bookmark_button',
     'bulk_delete_button',
     'bulk_delete_button',
@@ -25,9 +29,14 @@ __all__ = (
 register = template.Library()
 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)
 @register.inclusion_tag('buttons/bookmark.html', takes_context=True)
 def bookmark_button(context, instance):
 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)
 @register.inclusion_tag('buttons/subscribe.html', takes_context=True)
 def subscribe_button(context, instance):
 def subscribe_button(context, instance):
     # Skip for objects which don't support notifications
     # 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')
 @register.inclusion_tag('buttons/sync.html')
 def sync_button(instance):
 def sync_button(instance):
     viewname = get_viewname(instance, 'sync')
     viewname = get_viewname(instance, 'sync')
     url = reverse(viewname, kwargs={'pk': instance.pk})
     url = reverse(viewname, kwargs={'pk': instance.pk})
 
 
     return {
     return {
+        'label': _('Sync'),
         'url': url,
         'url': url,
     }
     }
 
 
 
 
 #
 #
-# List buttons
+# Legacy list buttons
 #
 #
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/add.html')
 @register.inclusion_tag('buttons/add.html')
 def add_button(model, action='add'):
 def add_button(model, action='add'):
     try:
     try:
@@ -154,9 +177,11 @@ def add_button(model, action='add'):
 
 
     return {
     return {
         'url': url,
         'url': url,
+        'label': _('Add'),
     }
     }
 
 
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/import.html')
 @register.inclusion_tag('buttons/import.html')
 def import_button(model, action='bulk_import'):
 def import_button(model, action='bulk_import'):
     try:
     try:
@@ -166,9 +191,11 @@ def import_button(model, action='bulk_import'):
 
 
     return {
     return {
         'url': url,
         'url': url,
+        'label': _('Import'),
     }
     }
 
 
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/export.html', takes_context=True)
 @register.inclusion_tag('buttons/export.html', takes_context=True)
 def export_button(context, model):
 def export_button(context, model):
     object_type = ObjectType.objects.get_for_model(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)
     export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
 
 
     return {
     return {
+        'label': _('Export'),
         'perms': context['perms'],
         'perms': context['perms'],
         'object_type': object_type,
         'object_type': object_type,
         'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
         '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)
 @register.inclusion_tag('buttons/bulk_edit.html', takes_context=True)
 def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
 def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
     try:
     try:
@@ -199,11 +228,13 @@ def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
         url = None
         url = None
 
 
     return {
     return {
-        'htmx_navigation': context.get('htmx_navigation'),
+        'label': _('Edit Selected'),
         'url': url,
         'url': url,
+        'htmx_navigation': context.get('htmx_navigation'),
     }
     }
 
 
 
 
+# TODO: Remove in NetBox v4.6
 @register.inclusion_tag('buttons/bulk_delete.html', takes_context=True)
 @register.inclusion_tag('buttons/bulk_delete.html', takes_context=True)
 def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
 def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
     try:
     try:
@@ -214,6 +245,7 @@ def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
         url = None
         url = None
 
 
     return {
     return {
-        'htmx_navigation': context.get('htmx_navigation'),
+        'label': _('Delete Selected'),
         'url': url,
         '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 extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import IPAddress, VLANGroup
 from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
-from netbox.constants import DEFAULT_ACTION_PERMISSIONS
+from netbox.object_actions import *
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
 from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
+from .object_actions import BulkAddComponents
 
 
 
 
 #
 #
@@ -204,6 +205,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
     filterset_form = forms.VirtualMachineFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit)
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Machines'),
         label=_('Virtual Machines'),
         badge=lambda obj: obj.virtual_machines.count(),
         badge=lambda obj: obj.virtual_machines.count(),
@@ -222,14 +224,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     table = DeviceTable
     table = DeviceTable
     filterset = DeviceFilterSet
     filterset = DeviceFilterSet
     filterset_form = DeviceFilterForm
     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(
     tab = ViewTab(
         label=_('Devices'),
         label=_('Devices'),
         badge=lambda obj: obj.devices.count(),
         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
 # Virtual machines
 #
 #
@@ -371,7 +322,7 @@ class VirtualMachineListView(generic.ObjectListView):
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
     filterset_form = forms.VirtualMachineFilterForm
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
-    template_name = 'virtualization/virtualmachine_list.html'
+    actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkDelete)
 
 
 
 
 @register_model_view(VirtualMachine)
 @register_model_view(VirtualMachine)
@@ -386,11 +337,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     table = tables.VirtualMachineVMInterfaceTable
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
     filterset = filtersets.VMInterfaceFilterSet
     filterset_form = forms.VMInterfaceFilterForm
     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(
     tab = ViewTab(
         label=_('Interfaces'),
         label=_('Interfaces'),
         badge=lambda obj: obj.interface_count,
         badge=lambda obj: obj.interface_count,
@@ -412,17 +359,13 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
     table = tables.VirtualMachineVirtualDiskTable
     table = tables.VirtualMachineVirtualDiskTable
     filterset = filtersets.VirtualDiskFilterSet
     filterset = filtersets.VirtualDiskFilterSet
     filterset_form = forms.VirtualDiskFilterForm
     filterset_form = forms.VirtualDiskFilterForm
-    template_name = 'virtualization/virtualmachine/virtual_disks.html'
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Disks'),
         label=_('Virtual Disks'),
         badge=lambda obj: obj.virtual_disk_count,
         badge=lambda obj: obj.virtual_disk_count,
         permission='virtualization.view_virtualdisk',
         permission='virtualization.view_virtualdisk',
         weight=500
         weight=500
     )
     )
-    actions = {
-        **DEFAULT_ACTION_PERMISSIONS,
-        'bulk_rename': {'change'},
-    }
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags')
         return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags')