Compare commits

...

21 Commits

Author SHA1 Message Date
Jason Novinger
002cf25a2c 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
2026-04-01 13:23:47 -05:00
Jeremy Stretch
2fb562fe50 Merge branch 'feature' into 21357-register-model-actions 2026-03-31 12:54:53 -04:00
Jason Novinger
2db5976184 Adapt custom actions panel for declarative layout system
Convert the ObjectPermission detail view to use the new panel-based
layout from #21568. Add ObjectPermissionCustomActionsPanel that
cross-references assigned object types with the model_actions registry
to display which models each custom action applies to.

Also fix dark-mode visibility of disabled action checkboxes in the
permission form by overriding Bootstrap's disabled opacity.
2026-03-30 14:27:29 -05:00
Jason Novinger
6ac5afc0e9 Validate action name is not empty and clarify RESERVED_ACTIONS origin 2026-03-30 12:56:34 -05:00
Jason Novinger
cf6599d9f8 Show all registered actions with enable/disable instead of show/hide 2026-03-30 12:56:34 -05:00
Jason Novinger
2bd8f9d677 Reject reserved action names in register_model_actions() 2026-03-30 12:56:34 -05:00
Jason Novinger
de41d0d3ae Refactor SplitMultiSelectWidget to use class attributes for widget classes 2026-03-30 12:56:34 -05:00
Jason Novinger
667702e0c2 Rebuild frontend assets after rebase onto feature 2026-03-30 12:56:34 -05:00
Jason Novinger
e6314e3971 Remove stale comment in RegisteredActionsWidget 2026-03-30 12:56:29 -05:00
Jason Novinger
80595c0f67 Prevent duplicate action registration in register_model_actions() 2026-03-30 12:56:29 -05:00
Jason Novinger
3f2734d5b8 Fix shared action pre-selection and additional actions leakage on edit 2026-03-30 12:56:29 -05:00
Jason Novinger
637ebf642c Add RESERVED_ACTIONS constant and fix dedup in registered actions
- Define RESERVED_ACTIONS in users/constants.py for the four built-in
  permission actions (view, add, change, delete)
- Replace hardcoded action lists in ObjectPermissionForm with the constant
- Fix duplicate action names in clean() when the same action is registered
  across multiple models (e.g. render_config for Device and VirtualMachine)
- Fix template substring matching bug in objectpermission.html detail view
  by passing RESERVED_ACTIONS through view context for proper list membership
2026-03-30 12:56:29 -05:00
Jason Novinger
92301949df Add documentation for custom model actions
- Add plugin development guide for registering custom actions
- Update admin permissions docs to mention custom actions UI
- Add docstrings to ModelAction and register_model_actions
2026-03-30 12:56:29 -05:00
Jason Novinger
0f5198e1b1 Hide custom actions field when no applicable models selected
The entire field row is now hidden when no selected object types
have registered custom actions, avoiding an empty "Custom actions"
label.
2026-03-30 12:56:29 -05:00
Jason Novinger
7541554d36 Refine registered actions widget UI
- Use verbose labels (App | Model) for action group headers
- Simplify template layout with h5 headers instead of cards
- Consolidate Standard/Custom/Additional Actions into single Actions fieldset
2026-03-30 12:56:29 -05:00
Jason Novinger
83888db109 Add tests for ModelAction and register_model_actions 2026-03-30 12:56:29 -05:00
Jason Novinger
02b85765d9 Register custom actions for DataSource, Device, and VirtualMachine 2026-03-30 12:56:29 -05:00
Jason Novinger
b2e0116302 Add JavaScript for registered actions show/hide 2026-03-30 12:56:29 -05:00
Jason Novinger
8926445ea2 Integrate registered actions into ObjectPermissionForm 2026-03-30 12:55:49 -05:00
Jason Novinger
5cfdf6ab6a Add ObjectTypeSplitMultiSelectWidget and RegisteredActionsWidget 2026-03-30 12:55:25 -05:00
Jason Novinger
2cfecd7052 Add ModelAction and register_model_actions() API for custom permission actions 2026-03-30 12:55:25 -05:00
27 changed files with 729 additions and 30 deletions

View File

@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
* **Change** - Modify an existing object
* **Delete** - Delete an existing object
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
Some models have registered custom actions that appear as checkboxes when creating or editing a permission. These are grouped by model under "Custom actions" in the permission form. Additional custom actions (such as those not yet registered or for backwards compatibility) can be entered manually in the "Additional actions" field.
!!! note
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.

View File

@@ -0,0 +1,36 @@
# Custom Model Actions
Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
## Registering Model Actions
To register custom actions for a model, call `register_model_actions()` in your plugin's `ready()` method:
```python
# __init__.py
from netbox.plugins import PluginConfig
class MyPluginConfig(PluginConfig):
name = 'my_plugin'
# ...
def ready(self):
super().ready()
from utilities.permissions import ModelAction, register_model_actions
from .models import MyModel
register_model_actions(MyModel, [
ModelAction('sync', help_text='Synchronize data from external source'),
ModelAction('export', help_text='Export data to external system'),
])
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.
::: utilities.permissions.ModelAction
::: utilities.permissions.register_model_actions

View File

@@ -152,6 +152,7 @@ nav:
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md'
- Permissions: 'plugins/development/permissions.md'
- Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
- User Interface: 'plugins/development/user-interface.md'

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

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

View File

@@ -28,6 +28,7 @@ registry = Registry({
'denormalized_fields': collections.defaultdict(list),
'event_types': dict(),
'filtersets': dict(),
'model_actions': collections.defaultdict(list),
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,17 @@
import { initClearField } from './clearField';
import { initFormElements } from './elements';
import { initFilterModifiers } from './filterModifiers';
import { initRegisteredActions } from './registeredActions';
import { initSpeedSelector } from './speedSelector';
export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
for (const func of [
initFormElements,
initSpeedSelector,
initFilterModifiers,
initClearField,
initRegisteredActions,
]) {
func();
}
}

View File

@@ -0,0 +1,60 @@
import { getElements } from '../util';
/**
* Enable/disable registered action checkboxes based on selected object_types.
*/
export function initRegisteredActions(): void {
const selectedList = document.getElementById('id_object_types_1') as HTMLSelectElement;
if (!selectedList) {
return;
}
const actionCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-models]'),
);
if (actionCheckboxes.length === 0) {
return;
}
function updateState(): void {
const selectedModels = new Set<string>();
// Get model keys from selected options
for (const option of Array.from(selectedList.options)) {
const modelKey = option.dataset.modelKey;
if (modelKey) {
selectedModels.add(modelKey);
}
}
// 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 the label text when disabled
const label = checkbox.nextElementSibling as HTMLElement | null;
if (label) {
label.style.opacity = enabled ? '' : '0.5';
}
}
}
// Initial update
updateState();
// Listen to move button clicks
for (const btn of getElements<HTMLButtonElement>('.move-option')) {
btn.addEventListener('click', () => {
// Wait for DOM update
setTimeout(updateState, 50);
});
}
}

View File

@@ -0,0 +1,20 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% block panel_content %}
<table class="table table-hover attr-table">
{% for action, models in custom_actions %}
<tr>
<th scope="row">{{ action }}</th>
<td>
<div class="d-flex justify-content-between align-items-start">
{% checkmark True %}
{% if models %}
<small class="text-muted">{{ models }}</small>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>
{% endblock panel_content %}

View File

@@ -10,6 +10,11 @@ OBJECTPERMISSION_OBJECT_TYPES = (
CONSTRAINT_TOKEN_USER = '$user'
# Django's four default model permissions. These receive special handling
# (dedicated checkboxes, model properties) and should not be registered
# as custom model actions.
RESERVED_ACTIONS = ('view', 'add', 'change', 'delete')
# API tokens
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
TOKEN_KEY_LENGTH = 12

View File

@@ -14,6 +14,7 @@ from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.config import get_config
from netbox.preferences import PREFERENCES
from netbox.registry import registry
from users.choices import TokenVersionChoices
from users.constants import *
from users.models import *
@@ -25,8 +26,8 @@ from utilities.forms.fields import (
JSONField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
from utilities.permissions import qs_filter_from_constraints
from utilities.forms.widgets import DateTimePicker, ObjectTypeSplitMultiSelectWidget, RegisteredActionsWidget
from utilities.permissions import get_action_model_map, qs_filter_from_constraints
from utilities.string import title
__all__ = (
@@ -325,7 +326,7 @@ class ObjectPermissionForm(forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.all(),
widget=SplitMultiSelectWidget(
widget=ObjectTypeSplitMultiSelectWidget(
choices=get_object_types_choices
),
help_text=_('Select the types of objects to which the permission will apply.')
@@ -342,11 +343,16 @@ class ObjectPermissionForm(forms.ModelForm):
can_delete = forms.BooleanField(
required=False
)
registered_actions = forms.MultipleChoiceField(
required=False,
widget=RegisteredActionsWidget(),
label=_('Custom actions'),
)
actions = SimpleArrayField(
label=_('Additional actions'),
base_field=forms.CharField(),
required=False,
help_text=_('Actions granted in addition to those listed above')
help_text=_('Additional actions for models which have not yet registered their own actions')
)
users = DynamicModelMultipleChoiceField(
label=_('Users'),
@@ -370,8 +376,11 @@ class ObjectPermissionForm(forms.ModelForm):
fieldsets = (
FieldSet('name', 'description', 'enabled'),
FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')),
FieldSet('object_types', name=_('Objects')),
FieldSet(
'can_view', 'can_add', 'can_change', 'can_delete', 'registered_actions', 'actions',
name=_('Actions')
),
FieldSet('groups', 'users', name=_('Assignment')),
FieldSet('constraints', name=_('Constraints')),
)
@@ -385,6 +394,25 @@ class ObjectPermissionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Build PK to model key mapping for object_types widget
pk_to_model_key = {
ot.pk: f'{ot.app_label}.{ot.model}'
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES)
}
self.fields['object_types'].widget.set_model_key_map(pk_to_model_key)
# Configure registered_actions widget and field choices (flat, deduplicated by name)
model_actions = dict(registry['model_actions'])
self.fields['registered_actions'].widget.set_model_actions(model_actions)
seen = set()
choices = []
for actions in model_actions.values():
for action in actions:
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
self.fields['actions'].required = False
@@ -394,11 +422,32 @@ class ObjectPermissionForm(forms.ModelForm):
self.fields['groups'].initial = self.instance.groups.values_list('id', flat=True)
self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
# Check the appropriate checkboxes when editing an existing ObjectPermission
for action in ['view', 'add', 'change', 'delete']:
if action in self.instance.actions:
# Work with a copy to avoid mutating the instance
remaining_actions = list(self.instance.actions)
# Check the appropriate CRUD checkboxes
for action in RESERVED_ACTIONS:
if action in remaining_actions:
self.fields[f'can_{action}'].initial = True
self.instance.actions.remove(action)
remaining_actions.remove(action)
# 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 and ma.name not in consumed_actions:
selected_registered.append(ma.name)
consumed_actions.add(ma.name)
self.fields['registered_actions'].initial = selected_registered
# Remaining actions go to the additional actions field
self.initial['actions'] = [
a for a in remaining_actions if a not in consumed_actions
]
# Populate initial data for a new ObjectPermission
elif self.initial:
@@ -408,7 +457,7 @@ class ObjectPermissionForm(forms.ModelForm):
if isinstance(self.initial['actions'], str):
self.initial['actions'] = [self.initial['actions']]
if cloned_actions := self.initial['actions']:
for action in ['view', 'add', 'change', 'delete']:
for action in RESERVED_ACTIONS:
if action in cloned_actions:
self.fields[f'can_{action}'].initial = True
self.initial['actions'].remove(action)
@@ -420,15 +469,41 @@ class ObjectPermissionForm(forms.ModelForm):
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
object_types = self.cleaned_data.get('object_types', [])
registered_actions = self.cleaned_data.get('registered_actions', [])
constraints = self.cleaned_data.get('constraints')
# Build set of selected model keys for validation
selected_models = {f'{ct.app_label}.{ct.model}' for ct in object_types}
# 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_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 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)
# Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'):
self.cleaned_data['actions'] = list()
for action in ['view', 'add', 'change', 'delete']:
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
self.cleaned_data['actions'].append(action)
for action in RESERVED_ACTIONS:
if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
final_actions.append(action)
# Add additional/manual actions
if additional_actions := self.cleaned_data.get('actions'):
for action in additional_actions:
if action not in final_actions:
final_actions.append(action)
self.cleaned_data['actions'] = final_actions
# At least one action must be specified
if not self.cleaned_data['actions']:

View File

@@ -1,6 +1,8 @@
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry
from netbox.ui import actions, attrs, panels
from users.constants import RESERVED_ACTIONS
class TokenPanel(panels.ObjectAttributesPanel):
@@ -54,6 +56,46 @@ class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
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()
}
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)
custom_actions_display = [
(action, ', '.join(action_models.get(action, [])))
for action in custom_actions
]
return {
**super().get_context(context),
'custom_actions': custom_actions_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')
group = attrs.RelatedObjectAttr('group', linkify=True)

View File

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

View File

@@ -1,3 +1,4 @@
from .actions import *
from .apiselect import *
from .datetime import *
from .misc import *

View File

@@ -0,0 +1,47 @@
from django import forms
from utilities.permissions import get_action_model_map
__all__ = (
'RegisteredActionsWidget',
)
class RegisteredActionsWidget(forms.CheckboxSelectMultiple):
"""
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 _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
def set_model_actions(self, model_actions):
self.model_actions = model_actions
self._build_maps()
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

@@ -9,6 +9,7 @@ __all__ = (
'ClearableSelect',
'ColorSelect',
'HTMXSelect',
'ObjectTypeSplitMultiSelectWidget',
'SelectWithPK',
'SplitMultiSelectWidget',
)
@@ -150,14 +151,16 @@ class SplitMultiSelectWidget(forms.MultiWidget):
be enabled only if the order of the selected choices is significant.
"""
template_name = 'widgets/splitmultiselect.html'
available_widget_class = AvailableOptions
selected_widget_class = SelectedOptions
def __init__(self, choices, attrs=None, ordering=False):
widgets = [
AvailableOptions(
self.available_widget_class(
attrs={'size': 8},
choices=choices
),
SelectedOptions(
self.selected_widget_class(
attrs={'size': 8, 'class': 'select-all'},
choices=choices
),
@@ -180,3 +183,48 @@ class SplitMultiSelectWidget(forms.MultiWidget):
def value_from_datadict(self, data, files, name):
# Return only the choices from the SelectedOptions widget
return super().value_from_datadict(data, files, name)[1]
#
# ObjectType-specific widgets for ObjectPermissionForm
#
class ObjectTypeSelectMultiple(SelectMultipleBase):
"""
SelectMultiple that adds data-model-key attribute to options for JS targeting.
"""
pk_to_model_key = None
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(name, value, label, selected, index, subindex, attrs)
if self.pk_to_model_key:
model_key = self.pk_to_model_key.get(value) or self.pk_to_model_key.get(str(value))
if model_key:
option['attrs']['data-model-key'] = model_key
return option
class ObjectTypeAvailableOptions(ObjectTypeSelectMultiple):
include_selected = False
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['attrs']['required'] = False
return context
class ObjectTypeSelectedOptions(ObjectTypeSelectMultiple):
include_selected = True
class ObjectTypeSplitMultiSelectWidget(SplitMultiSelectWidget):
"""
SplitMultiSelectWidget that adds data-model-key attributes to options.
Used by ObjectPermissionForm to enable JS show/hide of custom actions.
"""
available_widget_class = ObjectTypeAvailableOptions
selected_widget_class = ObjectTypeSelectedOptions
def set_model_key_map(self, pk_to_model_key):
for widget in self.widgets:
widget.pk_to_model_key = pk_to_model_key

View File

@@ -1,19 +1,84 @@
from dataclasses import dataclass
from django.apps import apps
from django.conf import settings
from django.db.models import Q
from django.db.models import Model, Q
from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
from netbox.registry import registry
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',
'register_model_actions',
'resolve_permission',
'resolve_permission_type',
)
@dataclass
class ModelAction:
"""
Represents a custom permission action for a model.
Attributes:
name: The action identifier (e.g. 'sync', 'render_config')
help_text: Optional description displayed in the ObjectPermission form
"""
name: str
help_text: str = ''
def __hash__(self):
return hash(self.name)
def __eq__(self, other):
if isinstance(other, ModelAction):
return self.name == other.name
return self.name == other
def register_model_actions(model: type[Model], actions: list[ModelAction | str]):
"""
Register custom permission actions for a model. These actions will appear as
checkboxes in the ObjectPermission form when the model is selected.
Args:
model: The model class to register actions for
actions: A list of ModelAction instances or action name strings
"""
label = f'{model._meta.app_label}.{model._meta.model_name}'
for action in actions:
if isinstance(action, str):
action = ModelAction(name=action)
if not action.name:
raise ValueError("Action name must not be empty.")
if action.name in RESERVED_ACTIONS:
raise ValueError(f"'{action.name}' is a reserved action and cannot be registered.")
if action not in registry['model_actions'][label]:
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

@@ -0,0 +1,19 @@
{# 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>
</div>
</div>
{% endfor %}{% endfor %}

View File

@@ -0,0 +1,185 @@
from django.test import TestCase
from core.models import ObjectType
from dcim.models import Device, Site
from netbox.registry import registry
from users.forms.model_forms import ObjectPermissionForm
from users.models import ObjectPermission
from utilities.permissions import ModelAction, register_model_actions
from virtualization.models import VirtualMachine
class ModelActionTest(TestCase):
def test_hash(self):
action1 = ModelAction(name='sync')
action2 = ModelAction(name='sync', help_text='Different help')
self.assertEqual(hash(action1), hash(action2))
def test_equality_with_model_action(self):
action1 = ModelAction(name='sync')
action2 = ModelAction(name='sync', help_text='Different help')
action3 = ModelAction(name='merge')
self.assertEqual(action1, action2)
self.assertNotEqual(action1, action3)
def test_equality_with_string(self):
action = ModelAction(name='sync')
self.assertEqual(action, 'sync')
self.assertNotEqual(action, 'merge')
def test_usable_in_set(self):
action1 = ModelAction(name='sync')
action2 = ModelAction(name='sync', help_text='Different')
action3 = ModelAction(name='merge')
actions = {action1, action2, action3}
self.assertEqual(len(actions), 2)
class RegisterModelActionsTest(TestCase):
def setUp(self):
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)
def test_register_model_action_objects(self):
register_model_actions(Site, [
ModelAction('test_action', help_text='Test help'),
])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 1)
self.assertEqual(actions[0].name, 'test_action')
self.assertEqual(actions[0].help_text, 'Test help')
def test_register_string_actions(self):
register_model_actions(Site, ['action1', 'action2'])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 2)
self.assertIsInstance(actions[0], ModelAction)
self.assertEqual(actions[0].name, 'action1')
self.assertEqual(actions[1].name, 'action2')
def test_register_mixed_actions(self):
register_model_actions(Site, [
ModelAction('with_help', help_text='Has help'),
'without_help',
])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 2)
self.assertEqual(actions[0].help_text, 'Has help')
self.assertEqual(actions[1].help_text, '')
def test_multiple_registrations_append(self):
register_model_actions(Site, [ModelAction('first')])
register_model_actions(Site, [ModelAction('second')])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 2)
self.assertEqual(actions[0].name, 'first')
self.assertEqual(actions[1].name, 'second')
def test_duplicate_registration_ignored(self):
register_model_actions(Site, [ModelAction('sync')])
register_model_actions(Site, [ModelAction('sync', help_text='Different help')])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 1)
def test_reserved_action_rejected(self):
for action_name in ('view', 'add', 'change', 'delete'):
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'])
registry['model_actions'].clear()
def tearDown(self):
registry['model_actions'].clear()
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')])
device_ct = ObjectType.objects.get_for_model(Device)
vm_ct = ObjectType.objects.get_for_model(VirtualMachine)
permission = ObjectPermission.objects.create(
name='Test Permission',
actions=['view', 'render_config'],
)
permission.object_types.set([device_ct, vm_ct])
form = ObjectPermissionForm(instance=permission)
initial = form.fields['registered_actions'].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

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