-
Notifications
You must be signed in to change notification settings - Fork 3k
Fixes #21357: Add API for registering custom model actions #21560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jnovinger
wants to merge
29
commits into
feature
Choose a base branch
from
21357-register-model-actions
base: feature
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
2cfecd7
Add ModelAction and register_model_actions() API for custom permissio…
jnovinger 5cfdf6a
Add ObjectTypeSplitMultiSelectWidget and RegisteredActionsWidget
jnovinger 8926445
Integrate registered actions into ObjectPermissionForm
jnovinger b2e0116
Add JavaScript for registered actions show/hide
jnovinger 02b8576
Register custom actions for DataSource, Device, and VirtualMachine
jnovinger 83888db
Add tests for ModelAction and register_model_actions
jnovinger 7541554
Refine registered actions widget UI
jnovinger 0f5198e
Hide custom actions field when no applicable models selected
jnovinger 9230194
Add documentation for custom model actions
jnovinger 637ebf6
Add RESERVED_ACTIONS constant and fix dedup in registered actions
jnovinger 3f2734d
Fix shared action pre-selection and additional actions leakage on edit
jnovinger 80595c0
Prevent duplicate action registration in register_model_actions()
jnovinger e6314e3
Remove stale comment in RegisteredActionsWidget
jnovinger 667702e
Rebuild frontend assets after rebase onto feature
jnovinger de41d0d
Refactor SplitMultiSelectWidget to use class attributes for widget cl…
jnovinger 2bd8f9d
Reject reserved action names in register_model_actions()
jnovinger cf6599d
Show all registered actions with enable/disable instead of show/hide
jnovinger 6ac5afc
Validate action name is not empty and clarify RESERVED_ACTIONS origin
jnovinger 2db5976
Adapt custom actions panel for declarative layout system
jnovinger 2fb562f
Merge branch 'feature' into 21357-register-model-actions
jeremystretch 002cf25
Flatten registered actions UI and declare via Meta.permissions
jnovinger 84c2acb
Address review feedback on registered actions
jnovinger e9be6e4
Consolidate ObjectPermission detail view actions panel
jnovinger 4c291f0
Address additional bot review feedback
jnovinger b5839d5
Update netbox/netbox/registry.py
jnovinger a57a538
Fix model_actions registry to use set operations
jnovinger 181c1ab
Rename permission migrations for clarity
jnovinger fa50e02
Move ModelAction validation into __post_init__
jnovinger 2e19ee6
Drop model name from permission descriptions
jnovinger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # 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 | ||
|
|
||
| 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 | ||
| 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 appear as checkboxes in a flat list when creating or editing an ObjectPermission. | ||
|
|
||
| ::: utilities.permissions.ModelAction | ||
|
|
||
| ::: utilities.permissions.register_model_actions |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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')]}, | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
netbox/dcim/migrations/0231_device_render_config_permission.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 configuration')]}, | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { getElements } from '../util'; | ||
|
|
||
| /** | ||
| * Enable/disable registered action checkboxes based on selected object_types. | ||
| */ | ||
| export function initRegisteredActions(): void { | ||
| const selectedList = document.querySelector<HTMLSelectElement>( | ||
| 'select[data-object-types-selected]', | ||
| ); | ||
|
|
||
| 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, 0); | ||
| }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| {% extends "ui/panels/_base.html" %} | ||
| {% load helpers %} | ||
|
|
||
| {% block panel_content %} | ||
| <table class="table table-hover attr-table"> | ||
| {% 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 enabled %} | ||
| {% if models %} | ||
| <small class="text-muted">{{ models }}</small> | ||
| {% endif %} | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| {% endfor %} | ||
| </table> | ||
| {% endblock panel_content %} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we rename these migrations to something like
foo_x_permission?