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.
This commit is contained in:
Jason Novinger
2026-04-01 17:16:57 -05:00
parent 84c2acb1f9
commit e9be6e4178
5 changed files with 50 additions and 40 deletions

View File

@@ -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

View File

@@ -3,12 +3,18 @@
{% block panel_content %}
<table class="table table-hover attr-table">
{% for action, models in custom_actions %}
{% for label, enabled in crud_actions %}
<tr>
<th scope="row">{{ label }}</th>
<td>{% checkmark enabled %}</td>
</tr>
{% endfor %}
{% for action, enabled, models in registered_actions %}
<tr>
<th scope="row">{{ action }}</th>
<td>
<div class="d-flex justify-content-between align-items-start">
{% checkmark True %}
{% checkmark enabled %}
{% if models %}
<small class="text-muted">{{ models }}</small>
{% endif %}

View File

@@ -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')

View File

@@ -272,7 +272,6 @@ class ObjectPermissionView(generic.ObjectView):
left_panels=[
panels.ObjectPermissionPanel(),
panels.ObjectPermissionActionsPanel(),
panels.ObjectPermissionCustomActionsPanel(),
JSONPanel('constraints', title=_('Constraints')),
],
right_panels=[

View File

@@ -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