Просмотр исходного кода

Closes #13550: Refactor view action mappings (#14062)

* Merge actions and action_perms into a single mapping

* Update obsolete permission maps

* Update obsolete action lists

* Normalize empty permission mappings

* Cleanup

* Add deprecation warnings

* Introduce DEFAULT_ACTION_PERMISSIONS constant
Jeremy Stretch 2 лет назад
Родитель
Сommit
450790ab4a

+ 7 - 2
netbox/core/views.py

@@ -100,7 +100,9 @@ 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',)
+    actions = {
+        'bulk_delete': {'delete'},
+    }
 
 
 
 
 @register_model_view(DataFile)
 @register_model_view(DataFile)
@@ -128,7 +130,10 @@ 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', 'delete', 'bulk_delete')
+    actions = {
+        'export': {'view'},
+        'bulk_delete': {'delete'},
+    }
 
 
 
 
 class JobView(generic.ObjectView):
 class JobView(generic.ObjectView):

+ 61 - 84
netbox/dcim/views.py

@@ -20,6 +20,7 @@ from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.tables import InterfaceVLANTable
 from ipam.tables import InterfaceVLANTable
+from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
@@ -46,15 +47,11 @@ CABLE_TERMINATION_TYPES = {
 
 
 
 
 class DeviceComponentsView(generic.ObjectChildrenView):
 class DeviceComponentsView(generic.ObjectChildrenView):
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
         'bulk_disconnect': {'change'},
         'bulk_disconnect': {'change'},
-    })
+    }
     queryset = Device.objects.all()
     queryset = Device.objects.all()
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
@@ -1977,7 +1974,10 @@ class DeviceModuleBaysView(DeviceComponentsView):
     table = tables.DeviceModuleBayTable
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
     filterset = filtersets.ModuleBayFilterSet
     template_name = 'dcim/device/modulebays.html'
     template_name = 'dcim/device/modulebays.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
+        'bulk_rename': {'change'},
+    }
     tab = ViewTab(
     tab = ViewTab(
         label=_('Module Bays'),
         label=_('Module Bays'),
         badge=lambda obj: obj.module_bay_count,
         badge=lambda obj: obj.module_bay_count,
@@ -1993,7 +1993,10 @@ class DeviceDeviceBaysView(DeviceComponentsView):
     table = tables.DeviceDeviceBayTable
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
     filterset = filtersets.DeviceBayFilterSet
     template_name = 'dcim/device/devicebays.html'
     template_name = 'dcim/device/devicebays.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
+        'bulk_rename': {'change'},
+    }
     tab = ViewTab(
     tab = ViewTab(
         label=_('Device Bays'),
         label=_('Device Bays'),
         badge=lambda obj: obj.device_bay_count,
         badge=lambda obj: obj.device_bay_count,
@@ -2005,11 +2008,14 @@ class DeviceDeviceBaysView(DeviceComponentsView):
 
 
 @register_model_view(Device, 'inventory')
 @register_model_view(Device, 'inventory')
 class DeviceInventoryView(DeviceComponentsView):
 class DeviceInventoryView(DeviceComponentsView):
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
     child_model = InventoryItem
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
     template_name = 'dcim/device/inventory.html'
     template_name = 'dcim/device/inventory.html'
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
+        'bulk_rename': {'change'},
+    }
     tab = ViewTab(
     tab = ViewTab(
         label=_('Inventory Items'),
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventory_item_count,
         badge=lambda obj: obj.inventory_item_count,
@@ -2187,14 +2193,10 @@ class ConsolePortListView(generic.ObjectListView):
     filterset_form = forms.ConsolePortFilterForm
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(ConsolePort)
 @register_model_view(ConsolePort)
@@ -2259,14 +2261,10 @@ class ConsoleServerPortListView(generic.ObjectListView):
     filterset_form = forms.ConsoleServerPortFilterForm
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(ConsoleServerPort)
 @register_model_view(ConsoleServerPort)
@@ -2331,14 +2329,10 @@ class PowerPortListView(generic.ObjectListView):
     filterset_form = forms.PowerPortFilterForm
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortTable
     table = tables.PowerPortTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(PowerPort)
 @register_model_view(PowerPort)
@@ -2403,14 +2397,10 @@ class PowerOutletListView(generic.ObjectListView):
     filterset_form = forms.PowerOutletFilterForm
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(PowerOutlet)
 @register_model_view(PowerOutlet)
@@ -2475,14 +2465,10 @@ class InterfaceListView(generic.ObjectListView):
     filterset_form = forms.InterfaceFilterForm
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(Interface)
 @register_model_view(Interface)
@@ -2595,14 +2581,10 @@ class FrontPortListView(generic.ObjectListView):
     filterset_form = forms.FrontPortFilterForm
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortTable
     table = tables.FrontPortTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(FrontPort)
 @register_model_view(FrontPort)
@@ -2667,14 +2649,10 @@ class RearPortListView(generic.ObjectListView):
     filterset_form = forms.RearPortFilterForm
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortTable
     table = tables.RearPortTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(RearPort)
 @register_model_view(RearPort)
@@ -2739,14 +2717,10 @@ class ModuleBayListView(generic.ObjectListView):
     filterset_form = forms.ModuleBayFilterForm
     filterset_form = forms.ModuleBayFilterForm
     table = tables.ModuleBayTable
     table = tables.ModuleBayTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(ModuleBay)
 @register_model_view(ModuleBay)
@@ -2803,14 +2777,10 @@ class DeviceBayListView(generic.ObjectListView):
     filterset_form = forms.DeviceBayFilterForm
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(DeviceBay)
 @register_model_view(DeviceBay)
@@ -2936,14 +2906,10 @@ class InventoryItemListView(generic.ObjectListView):
     filterset_form = forms.InventoryItemFilterForm
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     template_name = 'dcim/component_list.html'
     template_name = 'dcim/component_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
         'bulk_rename': {'change'},
         'bulk_rename': {'change'},
-    })
+    }
 
 
 
 
 @register_model_view(InventoryItem)
 @register_model_view(InventoryItem)
@@ -3175,7 +3141,12 @@ class CableListView(generic.ObjectListView):
     filterset = filtersets.CableFilterSet
     filterset = filtersets.CableFilterSet
     filterset_form = forms.CableFilterForm
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
     table = tables.CableTable
-    actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
+    actions = {
+        'import': {'add'},
+        'export': {'view'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+    }
 
 
 
 
 @register_model_view(Cable)
 @register_model_view(Cable)
@@ -3269,7 +3240,9 @@ 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',)
+    actions = {
+        'export': {'view'},
+    }
 
 
     def get_extra_context(self, request):
     def get_extra_context(self, request):
         return {
         return {
@@ -3283,7 +3256,9 @@ 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',)
+    actions = {
+        'export': {'view'},
+    }
 
 
     def get_extra_context(self, request):
     def get_extra_context(self, request):
         return {
         return {
@@ -3297,7 +3272,9 @@ 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',)
+    actions = {
+        'export': {'view'},
+    }
 
 
     def get_extra_context(self, request):
     def get_extra_context(self, request):
         return {
         return {

+ 27 - 6
netbox/extras/views.py

@@ -16,6 +16,7 @@ from core.tables import JobTable
 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 netbox.config import get_config, PARAMS
 from netbox.config import get_config, PARAMS
+from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.htmx import is_htmx
 from utilities.htmx import is_htmx
@@ -210,7 +211,10 @@ class ExportTemplateListView(generic.ObjectListView):
     filterset_form = forms.ExportTemplateFilterForm
     filterset_form = forms.ExportTemplateFilterForm
     table = tables.ExportTemplateTable
     table = tables.ExportTemplateTable
     template_name = 'extras/exporttemplate_list.html'
     template_name = 'extras/exporttemplate_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
+        'bulk_sync': {'sync'},
+    }
 
 
 
 
 @register_model_view(ExportTemplate)
 @register_model_view(ExportTemplate)
@@ -472,7 +476,12 @@ class ConfigContextListView(generic.ObjectListView):
     filterset_form = forms.ConfigContextFilterForm
     filterset_form = forms.ConfigContextFilterForm
     table = tables.ConfigContextTable
     table = tables.ConfigContextTable
     template_name = 'extras/configcontext_list.html'
     template_name = 'extras/configcontext_list.html'
-    actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
+    actions = {
+        'add': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_sync': {'sync'},
+    }
 
 
 
 
 @register_model_view(ConfigContext)
 @register_model_view(ConfigContext)
@@ -576,7 +585,10 @@ class ConfigTemplateListView(generic.ObjectListView):
     filterset_form = forms.ConfigTemplateFilterForm
     filterset_form = forms.ConfigTemplateFilterForm
     table = tables.ConfigTemplateTable
     table = tables.ConfigTemplateTable
     template_name = 'extras/configtemplate_list.html'
     template_name = 'extras/configtemplate_list.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
+        'bulk_sync': {'sync'},
+    }
 
 
 
 
 @register_model_view(ConfigTemplate)
 @register_model_view(ConfigTemplate)
@@ -627,7 +639,9 @@ class ObjectChangeListView(generic.ObjectListView):
     filterset_form = forms.ObjectChangeFilterForm
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
     table = tables.ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
-    actions = ('export',)
+    actions = {
+        'export': {'view'},
+    }
 
 
 
 
 @register_model_view(ObjectChange)
 @register_model_view(ObjectChange)
@@ -693,7 +707,9 @@ 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',)
+    actions = {
+        'export': {'view'},
+    }
 
 
 
 
 @register_model_view(ImageAttachment, 'edit')
 @register_model_view(ImageAttachment, 'edit')
@@ -736,7 +752,12 @@ 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 = ('import', 'export', 'bulk_edit', 'bulk_delete')
+    actions = {
+        'import': {'add'},
+        'export': {'view'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+    }
 
 
 
 
 @register_model_view(JournalEntry)
 @register_model_view(JournalEntry)

+ 9 - 0
netbox/netbox/constants.py

@@ -27,3 +27,12 @@ ADVISORY_LOCK_KEYS = {
     'inventoryitem': 105700,
     'inventoryitem': 105700,
     'inventoryitemtemplate': 105800,
     'inventoryitemtemplate': 105800,
 }
 }
+
+# Default view action permission mapping
+DEFAULT_ACTION_PERMISSIONS = {
+    'add': {'add'},
+    'import': {'add'},
+    'export': {'view'},
+    'bulk_edit': {'change'},
+    'bulk_delete': {'delete'},
+}

+ 48 - 13
netbox/netbox/views/generic/mixins.py

@@ -1,5 +1,6 @@
-from collections import defaultdict
+import warnings
 
 
+from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 
 
 __all__ = (
 __all__ = (
@@ -9,13 +10,15 @@ __all__ = (
 
 
 
 
 class ActionsMixin:
 class ActionsMixin:
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-    })
+    """
+    Maps action names to the set of required permissions for each. Object list views reference this mapping to
+    determine whether to render the applicable button for each action: The button will be rendered only if the user
+    possesses the specified permission(s).
+
+    Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
+    with custom actions, such as bulk_sync.
+    """
+    actions = DEFAULT_ACTION_PERMISSIONS
 
 
     def get_permitted_actions(self, user, model=None):
     def get_permitted_actions(self, user, model=None):
         """
         """
@@ -23,11 +26,43 @@ class ActionsMixin:
         """
         """
         model = model or self.queryset.model
         model = model or self.queryset.model
 
 
-        return [
-            action for action in self.actions if user.has_perms([
-                get_permission_for_model(model, name) for name in self.action_perms[action]
-            ])
-        ]
+        # TODO: Remove backward compatibility in Netbox v4.0
+        # Determine how permissions are being mapped to actions for the view
+        if hasattr(self, 'action_perms'):
+            # Backward compatibility for <3.7
+            permissions_map = self.action_perms
+            warnings.warn(
+                "Setting action_perms on views is deprecated and will be removed in NetBox v4.0. Use actions instead.",
+                DeprecationWarning
+            )
+        elif type(self.actions) is dict:
+            # New actions format (3.7+)
+            permissions_map = self.actions
+        else:
+            # actions is still defined as a list or tuple (<3.7) but no custom mapping is defined; use the old
+            # default mapping
+            permissions_map = {
+                'add': {'add'},
+                'import': {'add'},
+                'bulk_edit': {'change'},
+                'bulk_delete': {'delete'},
+            }
+            warnings.warn(
+                "View actions should be defined as a dictionary mapping. Support for the legacy list format will be "
+                "removed in NetBox v4.0.",
+                DeprecationWarning
+            )
+
+        # Resolve required permissions for each action
+        permitted_actions = []
+        for action in self.actions:
+            required_permissions = [
+                get_permission_for_model(model, name) for name in permissions_map.get(action, set())
+            ]
+            if not required_permissions or user.has_perms(required_permissions):
+                permitted_actions.append(action)
+
+        return permitted_actions
 
 
 
 
 class TableMixin:
 class TableMixin:

+ 5 - 1
netbox/tenancy/views.py

@@ -386,7 +386,11 @@ 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', 'bulk_edit', 'bulk_delete')
+    actions = {
+        'export': {'view'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+    }
 
 
 
 
 @register_model_view(ContactAssignment, 'edit')
 @register_model_view(ContactAssignment, 'edit')

+ 8 - 11
netbox/virtualization/views.py

@@ -16,6 +16,7 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress
 from ipam.models import IPAddress
 from ipam.tables import InterfaceVLANTable
 from ipam.tables import InterfaceVLANTable
+from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -199,13 +200,13 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     table = DeviceTable
     table = DeviceTable
     filterset = DeviceFilterSet
     filterset = DeviceFilterSet
     template_name = 'virtualization/cluster/devices.html'
     template_name = 'virtualization/cluster/devices.html'
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices')
-    action_perms = defaultdict(set, **{
+    actions = {
         'add': {'add'},
         'add': {'add'},
         'import': {'add'},
         'import': {'add'},
+        'export': {'view'},
         'bulk_edit': {'change'},
         'bulk_edit': {'change'},
         'bulk_remove_devices': {'change'},
         'bulk_remove_devices': {'change'},
-    })
+    }
     tab = ViewTab(
     tab = ViewTab(
         label=_('Devices'),
         label=_('Devices'),
         badge=lambda obj: obj.devices.count(),
         badge=lambda obj: obj.devices.count(),
@@ -359,20 +360,16 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     table = tables.VirtualMachineVMInterfaceTable
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
     filterset = filtersets.VMInterfaceFilterSet
     template_name = 'virtualization/virtualmachine/interfaces.html'
     template_name = 'virtualization/virtualmachine/interfaces.html'
+    actions = {
+        **DEFAULT_ACTION_PERMISSIONS,
+        'bulk_rename': {'change'},
+    }
     tab = ViewTab(
     tab = ViewTab(
         label=_('Interfaces'),
         label=_('Interfaces'),
         badge=lambda obj: obj.interface_count,
         badge=lambda obj: obj.interface_count,
         permission='virtualization.view_vminterface',
         permission='virtualization.view_vminterface',
         weight=500
         weight=500
     )
     )
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-        'bulk_rename': {'change'},
-    })
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.interfaces.restrict(request.user, 'view').prefetch_related(
         return parent.interfaces.restrict(request.user, 'view').prefetch_related(