From e9be6e41785484493ef54c0d869ce5024cf101df Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 1 Apr 2026 17:16:57 -0500 Subject: [PATCH] Consolidate ObjectPermission detail view actions panel Merge ObjectPermissionActionsPanel and ObjectPermissionCustomActionsPanel into a single Actions panel that shows CRUD booleans and all registered actions in one table, matching the form's consolidated layout. Also fix data-object-types-selected attribute value (True -> 'true') and update plugin docs to show Meta.permissions as the primary registration approach. --- docs/plugins/development/permissions.md | 19 +++++- .../{custom_actions.html => actions.html} | 10 +++- netbox/users/ui/panels.py | 58 ++++++++----------- netbox/users/views.py | 1 - netbox/utilities/forms/widgets/select.py | 2 +- 5 files changed, 50 insertions(+), 40 deletions(-) rename netbox/templates/users/panels/{custom_actions.html => actions.html} (64%) diff --git a/docs/plugins/development/permissions.md b/docs/plugins/development/permissions.md index 246254a97..792632a59 100644 --- a/docs/plugins/development/permissions.md +++ b/docs/plugins/development/permissions.md @@ -6,7 +6,22 @@ For example, a plugin might define a "sync" action for a model that syncs data f ## Registering Model Actions -To register custom actions for a model, call `register_model_actions()` in your plugin's `ready()` method: +The preferred way to register custom actions is via Django's `Meta.permissions` on the model class. NetBox will automatically register these as model actions when the app is loaded: + +```python +from netbox.models import NetBoxModel + +class MyModel(NetBoxModel): + # ... + + class Meta: + permissions = [ + ('sync', 'Synchronize data from external source'), + ('export', 'Export data to external system'), + ] +``` + +For dynamic registration (e.g. when actions depend on runtime state), you can call `register_model_actions()` directly, typically in your plugin's `ready()` method: ```python # __init__.py @@ -29,7 +44,7 @@ class MyPluginConfig(PluginConfig): config = MyPluginConfig ``` -Once registered, these actions will appear grouped under your model's name when creating or editing an ObjectPermission that includes your model as an object type. +Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission. ::: utilities.permissions.ModelAction diff --git a/netbox/templates/users/panels/custom_actions.html b/netbox/templates/users/panels/actions.html similarity index 64% rename from netbox/templates/users/panels/custom_actions.html rename to netbox/templates/users/panels/actions.html index 3ce1fd5e2..4fa7c28b7 100644 --- a/netbox/templates/users/panels/custom_actions.html +++ b/netbox/templates/users/panels/actions.html @@ -3,12 +3,18 @@ {% block panel_content %} - {% for action, models in custom_actions %} + {% for label, enabled in crud_actions %} + + + + + {% endfor %} + {% for action, enabled, models in registered_actions %}
{{ label }}{% checkmark enabled %}
{{ action }}
- {% checkmark True %} + {% checkmark enabled %} {% if models %} {{ models }} {% endif %} diff --git a/netbox/users/ui/panels.py b/netbox/users/ui/panels.py index 4e1106def..2b396db0b 100644 --- a/netbox/users/ui/panels.py +++ b/netbox/users/ui/panels.py @@ -47,54 +47,44 @@ class ObjectPermissionPanel(panels.ObjectAttributesPanel): enabled = attrs.BooleanAttr('enabled') -class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel): +class ObjectPermissionActionsPanel(panels.ObjectPanel): + template_name = 'users/panels/actions.html' title = _('Actions') - can_view = attrs.BooleanAttr('can_view', label=_('View')) - can_add = attrs.BooleanAttr('can_add', label=_('Add')) - can_change = attrs.BooleanAttr('can_change', label=_('Change')) - can_delete = attrs.BooleanAttr('can_delete', label=_('Delete')) - - -class ObjectPermissionCustomActionsPanel(panels.ObjectPanel): - """ - A panel which displays non-CRUD (custom) actions assigned to an ObjectPermission. - """ - template_name = 'users/panels/custom_actions.html' - title = _('Custom Actions') - def get_context(self, context): obj = context['object'] - custom_actions = [a for a in obj.actions if a not in RESERVED_ACTIONS] - # Build a list of (action_name, model_labels) tuples from the registry, - # scoped to the object types assigned to this permission. - assigned_types = { - f'{ot.app_label}.{ot.model}' for ot in obj.object_types.all() - } + crud_actions = [ + (_('View'), 'view' in obj.actions), + (_('Add'), 'add' in obj.actions), + (_('Change'), 'change' in obj.actions), + (_('Delete'), 'delete' in obj.actions), + ] + + enabled_actions = set(obj.actions) - set(RESERVED_ACTIONS) + + # Collect all registered actions from the full registry, deduplicating by name. + seen = [] + seen_set = set() action_models = {} for model_key, model_actions in registry['model_actions'].items(): - if model_key in assigned_types: - for action in model_actions: - if action.name in custom_actions: - action_models.setdefault(action.name, []).append(model_key) + for action in model_actions: + if action.name not in seen_set: + seen.append(action.name) + seen_set.add(action.name) + action_models.setdefault(action.name, []).append(model_key) - custom_actions_display = [ - (action, ', '.join(action_models.get(action, []))) - for action in custom_actions + registered_display = [ + (action, action in enabled_actions, ', '.join(sorted(action_models[action]))) + for action in seen ] return { **super().get_context(context), - 'custom_actions': custom_actions_display, + 'crud_actions': crud_actions, + 'registered_actions': registered_display, } - def render(self, context): - ctx = self.get_context(context) - if not ctx['custom_actions']: - return '' - return super().render(context) - class OwnerPanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') diff --git a/netbox/users/views.py b/netbox/users/views.py index 89e54ef82..59ebe518f 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -272,7 +272,6 @@ class ObjectPermissionView(generic.ObjectView): left_panels=[ panels.ObjectPermissionPanel(), panels.ObjectPermissionActionsPanel(), - panels.ObjectPermissionCustomActionsPanel(), JSONPanel('constraints', title=_('Constraints')), ], right_panels=[ diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 4941fe7f8..70bba98ce 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -218,7 +218,7 @@ class ObjectTypeSelectedOptions(ObjectTypeSelectMultiple): def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) - context['widget']['attrs']['data-object-types-selected'] = True + context['widget']['attrs']['data-object-types-selected'] = 'true' return context