Skip to content

Fixes #21357: Add API for registering custom model actions#21560

Open
jnovinger wants to merge 29 commits intofeaturefrom
21357-register-model-actions
Open

Fixes #21357: Add API for registering custom model actions#21560
jnovinger wants to merge 29 commits intofeaturefrom
21357-register-model-actions

Conversation

@jnovinger
Copy link
Copy Markdown
Member

@jnovinger jnovinger commented Mar 3, 2026

Fixes: #21357

Adds register_model_actions() API allowing plugins and core to register custom permission actions that appear as checkboxes in ObjectPermissionForm.

Changes:

  • Add model_actions registry store
  • Add ModelAction dataclass and register_model_actions() function in utilities.permissions
  • Add ObjectTypeSplitMultiSelectWidget with data attributes for JS targeting
  • Add RegisteredActionsWidget for rendering grouped action checkboxes
  • Update ObjectPermissionForm with consolidated Actions fieldset
  • Add JavaScript to show/hide actions based on selected object types
  • Register core actions: DataSource.sync, Device.render_config, VirtualMachine.render_config
  • Add unit tests for ModelAction and register_model_actions

Plugin usage:

# In plugin's apps.py ready() method
from utilities.permissions import ModelAction, register_model_actions
from .models import MyModel

register_model_actions(MyModel, [
    ModelAction('custom_action', help_text='Description of the action'),
])
image

Testing instructions:

  1. Navigate to Admin > Permissions > Object Permissions > Add
  2. In the "Object types" field, select models that have registered actions:
    • Data Source (Core)
    • Device (DCIM)
    • Virtual Machine (Virtualization)
  3. Observe the "Custom actions" section appears with checkboxes grouped by model
  4. Remove all models with registered actions - the "Custom actions" field should hide completely
  5. Select a mix of models with and without registered actions - only applicable action groups should show

@jnovinger jnovinger requested review from a team and jeremystretch and removed request for a team March 3, 2026 16:32
@jnovinger

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@jnovinger jnovinger marked this pull request as draft March 3, 2026 19:56
@jnovinger

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@jnovinger jnovinger force-pushed the 21357-register-model-actions branch from b473572 to 975910a Compare March 3, 2026 22:13
@jnovinger

This comment was marked as outdated.

@claude

This comment was marked as outdated.

@jnovinger
Copy link
Copy Markdown
Member Author

One Remaining Minor Item

UX: action names display as raw identifiers

netbox/utilities/templates/widgets/registered_actions.html:15

The checkbox label renders action.name directly (e.g. render_config, sync). Compare to NetBox's standard CRUD checkboxes which use proper labels ("View", "Add", etc.).

{{ action.name }}   {# Renders as "render_config" #}

ModelAction already has a help_text field for supplementary description, but no verbose_name for a display-friendly primary label.

This feels like scope creep to me. Let's tie it off here and revisit with a followup issue if needed.

@jnovinger jnovinger marked this pull request as ready for review March 3, 2026 23:20
Copy link
Copy Markdown
Member

@jeremystretch jeremystretch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI is a little scattered IMO: It's not made clear to the user the differences among actions, custom actions, and additional actions.

Image

Instead of dynamically adding/removing custom actions based on the selected objects, what do you think about starting by showing checkboxes for all available actions (both built-in and custom), and merely enabling/disabling them based on the selected objects? This would simplify the UI considerably, and it looks like you have the bulk of the necessary scripting in place already.

We should also extend the help text of the "additional actions" field to clarify that it is needed only when dealing with plugins which don't yet register their actions.

register_models(*self.get_models())

# Register custom permission actions
register_model_actions(DataSource, [
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we determine that it's feasible to declare permissions on each model directly, we can probably move this logic into register_models().

@jnovinger
Copy link
Copy Markdown
Member Author

Just saw the merge conflicts, fixing those now.

- 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
The entire field row is now hidden when no selected object types
have registered custom actions, avoiding an empty "Custom actions"
label.
- 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
- 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
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.
@jnovinger jnovinger force-pushed the 21357-register-model-actions branch from 30bfed1 to 2db5976 Compare March 30, 2026 19:27
@jnovinger
Copy link
Copy Markdown
Member Author

This is ready for review now, as soon as CI finishes.

Copy link
Copy Markdown
Member

@jeremystretch jeremystretch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify my earlier suggestion, let's consolidate these into a single list. Users don't know or care that a particular action is "custom" so there's no need to separate them out.

Image

It's also a little confusing to list duplicate actions, e.g. render_config. There's no significant different between them: So long as the render_config action is present for an ObjectPermission, it doesn't matter which model it came from.

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

This comment was marked as outdated.

@claude

This comment was marked as outdated.

- Sort model_keys in data-models attribute for deterministic output
- Rename registered_actions field label to 'Registered actions'
- Target object_types selected list via data-object-types-selected
  attribute instead of hardcoded DOM ID
- Reduce setTimeout delay to 0ms since moveOption() is synchronous
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.
@jnovinger

This comment was marked as outdated.

@claude

This comment was marked as outdated.

- clean() collects all validation errors before raising instead of stopping at the first
- Fix stale admin docs (still referenced "Custom actions" and "grouped by model")
@jnovinger jnovinger force-pushed the 21357-register-model-actions branch from 058429e to 4c291f0 Compare April 3, 2026 13:13
@jnovinger
Copy link
Copy Markdown
Member Author

Claude finished @jnovinger's task in 3m 53s —— View job

Review

...

Another round:

  • Admin docs still referenced "Custom actions" and "grouped by model"; updated to reflect the flat "Registered actions" layout
  • "Registered actions" label intentionally omitted from the form since the fieldset is already titled "Actions"
  • Raw model keys in the detail view (e.g. dcim.device) left as-is; not something Jeremy asked for
  • clean() now collects all validation errors before raising instead of stopping at the first
  • This one is wrong, help_text is already displayed.
  • Double-click path for moving options is out of scope; the existing .move-option listener covers the supported interaction

@jnovinger jnovinger requested a review from jeremystretch April 3, 2026 13:44
Copy link
Copy Markdown
Member

@jeremystretch jeremystretch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the UI functionality is pretty slick, I'm afraid there's way too much logic baked into the form and widgets to justify it. There's too much going on in the frontend for me to even review in a reasonable amount of time.

In the interest of moving this forward, I'm going to suggest a different approach: Rip out the fancy UI bits and just implement a regular form with a checkbox for each available permission. (Don't worry about enabling/disabling them dynamically.) IMO that's plenty sufficient to meet the needs of FR #21357. We can always revisit the advanced functionality at a later date, after the upcoming v4.6 release.

Copy link
Copy Markdown
Member

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?

Comment on lines +57 to +60
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.")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move these validation checks into ModelAction itself to ensure they're always applied?

class ModelAction:
    def __post_init__(self):
        if not self.name:
            raise ValueError("Action name must not be empty.")
        if self.name in RESERVED_ACTIONS:
            raise ValueError(f"'{action.name}' is a reserved action and cannot be registered.")

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]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can skip this check if we change registry['model_actions'] to defaultdict(set).

verbose_name = _('device')
verbose_name_plural = _('devices')
permissions = [
('render_config', 'Render device configuration'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest dropping the model name from these to avoid confusion in the UI.

Suggested change
('render_config', 'Render device configuration'),
('render_config', 'Render configuration'),

verbose_name = _('virtual machine')
verbose_name_plural = _('virtual machines')
permissions = [
('render_config', 'Render VM configuration'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
('render_config', 'Render VM configuration'),
('render_config', 'Render configuration'),


enabled_actions = set(obj.actions) - set(RESERVED_ACTIONS)

# Collect all registered actions from the full registry, deduplicating by name.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a lot of logic to bake into a UI panel. Suggest moving the logic for resolving registered actions to the ObjectPermission class.



#
# ObjectType-specific widgets for ObjectPermissionForm
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it's getting out-of-hand. We should avoid creating custom widgets for specific fields. This splits off from the original SplitMultiSelectWidget class, which is now unused.

jnovinger and others added 5 commits April 3, 2026 14:38
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
The registry was changed to defaultdict(set) but the registration
code still used list methods. Update .append() to .add() and fix
tests to use set-compatible access patterns.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants