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.
This commit is contained in:
Jason Novinger
2026-03-30 14:27:29 -05:00
parent 6ac5afc0e9
commit 2db5976184
7 changed files with 81 additions and 113 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -30,13 +30,20 @@ export function initRegisteredActions(): void {
const enabled = modelKey !== null && selectedModels.has(modelKey);
const el = group as HTMLElement;
el.style.opacity = enabled ? '1' : '0.4';
// Toggle disabled on checkboxes within the group
// 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';
}
// Fade text for disabled groups
for (const label of Array.from(
el.querySelectorAll<HTMLElement>('small, .form-check-label'),
)) {
label.style.opacity = enabled ? '' : '0.5';
}
});
}

View File

@@ -1,101 +1,5 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Permission" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Actions" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{% checkmark object.can_view %}</td>
</tr>
<tr>
<th scope="row">{% trans "Add" %}</th>
<td>{% checkmark object.can_add %}</td>
</tr>
<tr>
<th scope="row">{% trans "Change" %}</th>
<td>{% checkmark object.can_change %}</td>
</tr>
<tr>
<th scope="row">{% trans "Delete" %}</th>
<td>{% checkmark object.can_delete %}</td>
</tr>
{% for action in object.actions %}
{% if action not in reserved_actions %}
<tr>
<th scope="row">{{ action }}</th>
<td>{% checkmark True %}</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Constraints" %}</h2>
<div class="card-body">
{% if object.constraints %}
<pre>{{ object.constraints|json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Object Types" %}</h2>
<ul class="list-group list-group-flush">
{% for user in object.object_types.all %}
<li class="list-group-item">{{ user }}</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

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

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

@@ -19,7 +19,6 @@ from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .constants import RESERVED_ACTIONS
from .models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
#
@@ -273,6 +272,7 @@ class ObjectPermissionView(generic.ObjectView):
left_panels=[
panels.ObjectPermissionPanel(),
panels.ObjectPermissionActionsPanel(),
panels.ObjectPermissionCustomActionsPanel(),
JSONPanel('constraints', title=_('Constraints')),
],
right_panels=[
@@ -286,11 +286,6 @@ class ObjectPermissionView(generic.ObjectView):
],
)
def get_extra_context(self, request, instance):
return {
'reserved_actions': RESERVED_ACTIONS,
}
@register_model_view(ObjectPermission, 'add', detail=False)
@register_model_view(ObjectPermission, 'edit')