Flatten registered actions UI and declare via Meta.permissions

Implement two changes requested in review of #21560:

1. Use Meta.permissions for action declaration
   - Add Meta.permissions to DataSource, Device, and VirtualMachine
   - register_models() auto-registers actions from Meta.permissions
   - Remove explicit register_model_actions() calls from apps.py
   - Add get_action_model_map() utility to utilities/permissions.py

2. Flatten the ObjectPermission form UI
   - Show a single deduplicated list of action checkboxes (one per
     unique action name) instead of grouped-by-model checkboxes
   - RegisteredActionsWidget uses create_option() to inject model_keys
     and help_text; JS enables/disables based on selected object types
   - render_field.html bypasses outer wrapper for registeredactionswidget
     so widget emits rows with identical DOM structure to CRUD checkboxes
   - Unchecking a model now also unchecks unsupported action checkboxes

Fixes #21357
This commit is contained in:
Jason Novinger
2026-04-01 13:23:47 -05:00
parent 2fb562fe50
commit 002cf25a2c
19 changed files with 259 additions and 113 deletions

View File

@@ -25,19 +25,12 @@ class CoreConfig(AppConfig):
from core.checks import check_duplicate_indexes # noqa: F401
from netbox import context_managers # noqa: F401
from netbox.models.features import register_models
from utilities.permissions import ModelAction, register_model_actions
from . import data_backends, events, search # noqa: F401
from .models import DataSource
# Register models
register_models(*self.get_models())
# Register custom permission actions
register_model_actions(DataSource, [
ModelAction('sync', help_text=_('Synchronize data from remote source')),
])
# Register core events
EventType(OBJECT_CREATED, _('Object created')).register()
EventType(OBJECT_UPDATED, _('Object updated')).register()

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.11 on 2026-03-31 21:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0021_job_queue_name'),
]
operations = [
migrations.AlterModelOptions(
name='datasource',
options={'ordering': ('name',), 'permissions': [('sync', 'Synchronize data from remote source')]},
),
]

View File

@@ -87,6 +87,9 @@ class DataSource(JobsMixin, PrimaryModel):
ordering = ('name',)
verbose_name = _('data source')
verbose_name_plural = _('data sources')
permissions = [
('sync', 'Synchronize data from remote source'),
]
def __str__(self):
return f'{self.name}'

View File

@@ -8,11 +8,8 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM"
def ready(self):
from django.utils.translation import gettext as _
from netbox.models.features import register_models
from utilities.counters import connect_counters
from utilities.permissions import ModelAction, register_model_actions
from . import search, signals # noqa: F401
from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
@@ -20,11 +17,6 @@ class DCIMConfig(AppConfig):
# Register models
register_models(*self.get_models())
# Register custom permission actions
register_model_actions(Device, [
ModelAction('render_config', help_text=_('Render device configuration')),
])
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
'_rack': 'rack',

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.11 on 2026-03-31 21:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0230_interface_rf_channel_frequency_precision'),
]
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render device configuration')]},
),
]

View File

@@ -759,6 +759,9 @@ class Device(
)
verbose_name = _('device')
verbose_name_plural = _('devices')
permissions = [
('render_config', 'Render device configuration'),
]
def __str__(self):
if self.label and self.asset_tag:

View File

@@ -25,6 +25,7 @@ from netbox.registry import registry
from netbox.signals import post_clean
from netbox.utils import register_model_feature
from utilities.json import CustomFieldJSONEncoder
from utilities.permissions import ModelAction, register_model_actions
from utilities.serialization import serialize_object
__all__ = (
@@ -752,3 +753,12 @@ def register_models(*models):
register_model_view(model, 'sync', kwargs={'model': model})(
'netbox.views.generic.ObjectSyncDataView'
)
# Auto-register custom permission actions declared in Meta.permissions
if meta_permissions := getattr(model._meta, 'permissions', None):
actions = [
ModelAction(codename, help_text=_(name))
for codename, name in meta_permissions
]
if actions:
register_model_actions(model, actions)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,10 +4,17 @@ import { getElements } from '../util';
* Enable/disable registered action checkboxes based on selected object_types.
*/
export function initRegisteredActions(): void {
const actionsContainer = document.getElementById('id_registered_actions_container');
const selectedList = document.getElementById('id_object_types_1') as HTMLSelectElement;
if (!actionsContainer || !selectedList) {
if (!selectedList) {
return;
}
const actionCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-models]'),
);
if (actionCheckboxes.length === 0) {
return;
}
@@ -22,30 +29,22 @@ export function initRegisteredActions(): void {
}
}
// Enable/disable action groups based on selected models
const groups = actionsContainer!.querySelectorAll('.model-actions');
groups.forEach(group => {
const modelKey = group.getAttribute('data-model');
const enabled = modelKey !== null && selectedModels.has(modelKey);
const el = group as HTMLElement;
// Toggle disabled on checkboxes, overriding Bootstrap's disabled opacity
// to keep them visible in dark mode
for (const checkbox of Array.from(
el.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
)) {
checkbox.disabled = !enabled;
checkbox.style.opacity = enabled ? '' : '0.75';
// Enable a checkbox if any of its supported models is selected
for (const checkbox of actionCheckboxes) {
const modelKeys = (checkbox.dataset.models ?? '').split(',').filter(Boolean);
const enabled = modelKeys.some(m => selectedModels.has(m));
checkbox.disabled = !enabled;
if (!enabled) {
checkbox.checked = false;
}
checkbox.style.opacity = enabled ? '' : '0.75';
// Fade text for disabled groups
for (const label of Array.from(
el.querySelectorAll<HTMLElement>('small, .form-check-label'),
)) {
// Fade the label text when disabled
const label = checkbox.nextElementSibling as HTMLElement | null;
if (label) {
label.style.opacity = enabled ? '' : '0.5';
}
});
}
}
// Initial update

View File

@@ -27,7 +27,7 @@ from utilities.forms.fields import (
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker, ObjectTypeSplitMultiSelectWidget, RegisteredActionsWidget
from utilities.permissions import qs_filter_from_constraints
from utilities.permissions import get_action_model_map, qs_filter_from_constraints
from utilities.string import title
__all__ = (
@@ -401,13 +401,16 @@ class ObjectPermissionForm(forms.ModelForm):
}
self.fields['object_types'].widget.set_model_key_map(pk_to_model_key)
# Configure registered_actions widget and field choices
# Configure registered_actions widget and field choices (flat, deduplicated by name)
model_actions = dict(registry['model_actions'])
self.fields['registered_actions'].widget.model_actions = model_actions
self.fields['registered_actions'].widget.set_model_actions(model_actions)
seen = set()
choices = []
for model_key, actions in model_actions.items():
for actions in model_actions.values():
for action in actions:
choices.append((f'{model_key}.{action.name}', action.name))
if action.name not in seen:
choices.append((action.name, action.name))
seen.add(action.name)
self.fields['registered_actions'].choices = choices
# Make the actions field optional since the form uses it only for non-CRUD actions
@@ -428,15 +431,16 @@ class ObjectPermissionForm(forms.ModelForm):
self.fields[f'can_{action}'].initial = True
remaining_actions.remove(action)
# Pre-select registered actions
# Pre-select registered actions: action is checked if it's in instance.actions
# AND at least one assigned object type supports it
selected_registered = []
consumed_actions = set()
for ct in self.instance.object_types.all():
model_key = f'{ct.app_label}.{ct.model}'
if model_key in model_actions:
for ma in model_actions[model_key]:
if ma.name in remaining_actions:
selected_registered.append(f'{model_key}.{ma.name}')
if ma.name in remaining_actions and ma.name not in consumed_actions:
selected_registered.append(ma.name)
consumed_actions.add(ma.name)
self.fields['registered_actions'].initial = selected_registered
@@ -472,15 +476,18 @@ class ObjectPermissionForm(forms.ModelForm):
# Build set of selected model keys for validation
selected_models = {f'{ct.app_label}.{ct.model}' for ct in object_types}
# Validate registered actions match selected object_types and collect action names
# Build map of action_name -> set of model_keys that support it
action_model_keys = get_action_model_map(dict(registry['model_actions']))
# Validate each selected action is supported by at least one selected object type
final_actions = []
for action_key in registered_actions:
model_key, action_name = action_key.rsplit('.', 1)
if model_key not in selected_models:
for action_name in registered_actions:
supported_models = action_model_keys.get(action_name, set())
if not supported_models & selected_models:
raise forms.ValidationError({
'registered_actions': _(
'Action "{action}" is for {model} which is not selected.'
).format(action=action_name, model=model_key)
'Action "{action}" is not supported by any of the selected object types.'
).format(action=action_name)
})
if action_name not in final_actions:
final_actions.append(action_name)

View File

@@ -1,5 +1,6 @@
from django import forms
from django.apps import apps
from utilities.permissions import get_action_model_map
__all__ = (
'RegisteredActionsWidget',
@@ -8,32 +9,39 @@ __all__ = (
class RegisteredActionsWidget(forms.CheckboxSelectMultiple):
"""
Widget rendering checkboxes for registered model actions.
Groups actions by model with data attributes for JS show/hide.
Widget for registered model actions. Renders each action as an individual checkbox
row styled identically to the CRUD checkboxes, with a data-models attribute so JS
can enable/disable based on the currently selected object types.
"""
template_name = 'widgets/registered_actions.html'
def __init__(self, *args, model_actions=None, **kwargs):
super().__init__(*args, **kwargs)
self.model_actions = model_actions or {}
self._action_model_keys = {}
self._action_help_text = {}
self._build_maps()
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
def _build_maps(self):
self._action_model_keys = get_action_model_map(self.model_actions)
self._action_help_text = {}
for actions in self.model_actions.values():
for action in actions:
if action.name not in self._action_help_text:
self._action_help_text[action.name] = action.help_text
model_actions_with_labels = {}
for model_key, actions in self.model_actions.items():
app_label, model_name = model_key.split('.')
try:
model = apps.get_model(app_label, model_name)
app_config = apps.get_app_config(app_label)
label = f"{app_config.verbose_name} | {model._meta.verbose_name.title()}"
except LookupError:
label = model_key
model_actions_with_labels[model_key] = {
'label': label,
'actions': actions,
}
def set_model_actions(self, model_actions):
self.model_actions = model_actions
self._build_maps()
context['widget']['model_actions'] = model_actions_with_labels
context['widget']['value'] = value or []
return context
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
"""
Inject model_keys (comma-separated model keys that support this action) and
help_text into the option dict. The template uses model_keys for the data-models
attribute, which JS reads to enable/disable checkboxes based on selected object types.
"""
option = super().create_option(name, value, label, selected, index, subindex=subindex, attrs=attrs)
action_name = str(value)
option['model_keys'] = ','.join(self._action_model_keys.get(action_name, set()))
option['help_text'] = self._action_help_text.get(action_name, '')
return option

View File

@@ -10,6 +10,7 @@ from users.constants import CONSTRAINT_TOKEN_USER, RESERVED_ACTIONS
__all__ = (
'ModelAction',
'get_action_model_map',
'get_permission_for_model',
'permission_is_exempt',
'qs_filter_from_constraints',
@@ -61,6 +62,23 @@ def register_model_actions(model: type[Model], actions: list[ModelAction | str])
registry['model_actions'][label].append(action)
def get_action_model_map(model_actions):
"""
Build a mapping of action name to the set of model keys that support it.
Args:
model_actions: Dict of {model_key: [ModelAction, ...]} from the registry
Returns:
Dict of {action_name: {model_key, ...}}
"""
mapping = {}
for model_key, actions in model_actions.items():
for action in actions:
mapping.setdefault(action.name, set()).add(model_key)
return mapping
def get_permission_for_model(model, action):
"""
Resolve the named permission for a given model (or instance) and action (e.g. view or add).

View File

@@ -2,6 +2,19 @@
{% load helpers %}
{% load i18n %}
{# RegisteredActionsWidget emits its own row markup per option #}
{% if field|widget_type == 'registeredactionswidget' %}
{{ field }}
{% if field.errors %}
<div class="row mb-3">
<div class="col offset-3">
<div class="form-text text-danger">
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
{# Render the field label (if any), except for checkboxes #}
@@ -70,3 +83,4 @@
</div>
</div>
{% endif %}

View File

@@ -1,25 +1,19 @@
{% load i18n %}
<div class="registered-actions-container" id="id_registered_actions_container">
{% for model_key, model_data in widget.model_actions.items %}
<div class="model-actions" data-model="{{ model_key }}">
<small class="text-muted d-block mt-3 mb-1">{{ model_data.label }}</small>
{% for action in model_data.actions %}
<div class="form-check mb-0">
<input type="checkbox"
class="form-check-input"
name="{{ widget.name }}"
value="{{ model_key }}.{{ action.name }}"
id="id_{{ widget.name }}_{{ forloop.parentloop.counter }}_{{ forloop.counter }}"
disabled
{% if model_key|add:"."|add:action.name in widget.value %}checked{% endif %}>
<label class="form-check-label" for="id_{{ widget.name }}_{{ forloop.parentloop.counter }}_{{ forloop.counter }}">
{{ action.name }}
{% if action.help_text %}
<small class="text-muted ms-1">{{ action.help_text }}</small>
{% endif %}
</label>
</div>
{% endfor %}
{# optgroups is Django's CheckboxSelectMultiple context: list of (group_name, options, index) tuples #}
{% for group_name, options, index in widget.optgroups %}{% for option in options %}
<div class="row mb-3">
<div class="col offset-3">
<div class="form-check mb-0">
<input type="checkbox"
class="form-check-input"
name="{{ option.name }}"
value="{{ option.value }}"
id="{{ option.attrs.id }}"
data-models="{{ option.model_keys }}"
{% if option.selected %}checked{% endif %}>
<label class="form-check-label" for="{{ option.attrs.id }}">
{{ option.label }}{% if option.help_text %} <small class="text-muted ms-1">{{ option.help_text }}</small>{% endif %}
</label>
</div>
{% endfor %}
</div>
</div>
{% endfor %}{% endfor %}

View File

@@ -39,11 +39,12 @@ class ModelActionTest(TestCase):
class RegisterModelActionsTest(TestCase):
def setUp(self):
self.original_actions = dict(registry['model_actions'])
self._original_actions = dict(registry['model_actions'])
registry['model_actions'].clear()
def tearDown(self):
registry['model_actions'].clear()
registry['model_actions'].update(self.original_actions)
registry['model_actions'].update(self._original_actions)
def test_register_model_action_objects(self):
register_model_actions(Site, [
@@ -91,17 +92,39 @@ class RegisterModelActionsTest(TestCase):
with self.assertRaises(ValueError):
register_model_actions(Site, [ModelAction(action_name)])
def test_empty_action_name_rejected(self):
with self.assertRaises(ValueError):
register_model_actions(Site, [ModelAction('')])
def test_flat_choices_no_duplicates(self):
"""Same action name registered for multiple models should appear once in flat choices."""
register_model_actions(Device, [ModelAction('render_config')])
register_model_actions(VirtualMachine, [ModelAction('render_config')])
device_ct = ObjectType.objects.get_for_model(Device)
form = ObjectPermissionForm(data={
'name': 'test',
'object_types_0': [],
'object_types_1': [device_ct.pk],
'can_view': True,
})
choices = form.fields['registered_actions'].choices
names = [name for name, label in choices]
self.assertEqual(names.count('render_config'), 1)
class ObjectPermissionFormTest(TestCase):
def setUp(self):
self.original_actions = dict(registry['model_actions'])
self._original_actions = dict(registry['model_actions'])
registry['model_actions'].clear()
def tearDown(self):
registry['model_actions'].clear()
registry['model_actions'].update(self.original_actions)
registry['model_actions'].update(self._original_actions)
def test_shared_action_preselection(self):
"""Editing a permission with render_config should pre-select 'render_config' (plain name)."""
register_model_actions(Device, [ModelAction('render_config')])
register_model_actions(VirtualMachine, [ModelAction('render_config')])
@@ -117,10 +140,46 @@ class ObjectPermissionFormTest(TestCase):
form = ObjectPermissionForm(instance=permission)
initial = form.fields['registered_actions'].initial
self.assertIn('dcim.device.render_config', initial)
self.assertIn('virtualization.virtualmachine.render_config', initial)
self.assertIn('render_config', initial)
# Should not use the old model-prefixed format
self.assertNotIn('dcim.device.render_config', initial)
self.assertNotIn('virtualization.virtualmachine.render_config', initial)
# Should only appear once despite being registered for two models
self.assertEqual(initial.count('render_config'), 1)
# Should not leak into the additional actions field
self.assertEqual(form.initial['actions'], [])
permission.delete()
def test_clean_accepts_valid_registered_action(self):
"""clean() should accept a plain action name when the matching object type is selected."""
register_model_actions(Device, [ModelAction('render_config')])
device_ct = ObjectType.objects.get_for_model(Device)
form = ObjectPermissionForm(data={
'name': 'test perm',
'object_types_0': [],
'object_types_1': [device_ct.pk],
'registered_actions': ['render_config'],
'can_view': True,
'actions': '',
})
self.assertTrue(form.is_valid(), form.errors)
self.assertIn('render_config', form.cleaned_data['actions'])
def test_clean_rejects_action_without_matching_object_type(self):
"""clean() should reject a registered action when no matching object type is selected."""
register_model_actions(Device, [ModelAction('render_config')])
site_ct = ObjectType.objects.get_for_model(Site)
form = ObjectPermissionForm(data={
'name': 'test perm',
'object_types_0': [],
'object_types_1': [site_ct.pk],
'registered_actions': ['render_config'],
'can_view': True,
'actions': '',
})
self.assertFalse(form.is_valid())
self.assertIn('registered_actions', form.errors)

View File

@@ -5,11 +5,8 @@ class VirtualizationConfig(AppConfig):
name = 'virtualization'
def ready(self):
from django.utils.translation import gettext as _
from netbox.models.features import register_models
from utilities.counters import connect_counters
from utilities.permissions import ModelAction, register_model_actions
from . import search, signals # noqa: F401
from .models import VirtualMachine, VirtualMachineType
@@ -19,8 +16,3 @@ class VirtualizationConfig(AppConfig):
# Register counters
connect_counters(VirtualMachine, VirtualMachineType)
# Register custom permission actions
register_model_actions(VirtualMachine, [
ModelAction('render_config', help_text=_('Render VM configuration')),
])

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.11 on 2026-04-01 16:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0054_virtualmachinetype'),
]
operations = [
migrations.AlterModelOptions(
name='virtualmachine',
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render VM configuration')]},
),
]

View File

@@ -276,6 +276,9 @@ class VirtualMachine(
)
verbose_name = _('virtual machine')
verbose_name_plural = _('virtual machines')
permissions = [
('render_config', 'Render VM configuration'),
]
def __str__(self):
return self.name