mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-02 15:37:18 +02:00
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:
@@ -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()
|
||||
|
||||
17
netbox/core/migrations/0022_alter_datasource_options.py
Normal file
17
netbox/core/migrations/0022_alter_datasource_options.py
Normal 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')]},
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
@@ -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',
|
||||
|
||||
17
netbox/dcim/migrations/0231_alter_device_options.py
Normal file
17
netbox/dcim/migrations/0231_alter_device_options.py
Normal 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')]},
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
])
|
||||
|
||||
@@ -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')]},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user