mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-02 15:37:18 +02:00
Compare commits
23 Commits
21770-embe
...
21357-regi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9be6e4178 | ||
|
|
84c2acb1f9 | ||
|
|
002cf25a2c | ||
|
|
2fb562fe50 | ||
|
|
2db5976184 | ||
|
|
6ac5afc0e9 | ||
|
|
cf6599d9f8 | ||
|
|
2bd8f9d677 | ||
|
|
de41d0d3ae | ||
|
|
667702e0c2 | ||
|
|
e6314e3971 | ||
|
|
80595c0f67 | ||
|
|
3f2734d5b8 | ||
|
|
637ebf642c | ||
|
|
92301949df | ||
|
|
0f5198e1b1 | ||
|
|
7541554d36 | ||
|
|
83888db109 | ||
|
|
02b85765d9 | ||
|
|
b2e0116302 | ||
|
|
8926445ea2 | ||
|
|
5cfdf6ab6a | ||
|
|
2cfecd7052 |
@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
|
|||||||
* **Change** - Modify an existing object
|
* **Change** - Modify an existing object
|
||||||
* **Delete** - Delete 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
|
!!! note
|
||||||
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
|
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
|
||||||
|
|||||||
51
docs/plugins/development/permissions.md
Normal file
51
docs/plugins/development/permissions.md
Normal file
@@ -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
|
||||||
@@ -152,6 +152,7 @@ nav:
|
|||||||
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
||||||
- Search: 'plugins/development/search.md'
|
- Search: 'plugins/development/search.md'
|
||||||
- Event Types: 'plugins/development/event-types.md'
|
- Event Types: 'plugins/development/event-types.md'
|
||||||
|
- Permissions: 'plugins/development/permissions.md'
|
||||||
- Data Backends: 'plugins/development/data-backends.md'
|
- Data Backends: 'plugins/development/data-backends.md'
|
||||||
- Webhooks: 'plugins/development/webhooks.md'
|
- Webhooks: 'plugins/development/webhooks.md'
|
||||||
- User Interface: 'plugins/development/user-interface.md'
|
- User Interface: 'plugins/development/user-interface.md'
|
||||||
|
|||||||
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',)
|
ordering = ('name',)
|
||||||
verbose_name = _('data source')
|
verbose_name = _('data source')
|
||||||
verbose_name_plural = _('data sources')
|
verbose_name_plural = _('data sources')
|
||||||
|
permissions = [
|
||||||
|
('sync', 'Synchronize data from remote source'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name}'
|
return f'{self.name}'
|
||||||
|
|||||||
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 = _('device')
|
||||||
verbose_name_plural = _('devices')
|
verbose_name_plural = _('devices')
|
||||||
|
permissions = [
|
||||||
|
('render_config', 'Render device configuration'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.label and self.asset_tag:
|
if self.label and self.asset_tag:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from netbox.registry import registry
|
|||||||
from netbox.signals import post_clean
|
from netbox.signals import post_clean
|
||||||
from netbox.utils import register_model_feature
|
from netbox.utils import register_model_feature
|
||||||
from utilities.json import CustomFieldJSONEncoder
|
from utilities.json import CustomFieldJSONEncoder
|
||||||
|
from utilities.permissions import ModelAction, register_model_actions
|
||||||
from utilities.serialization import serialize_object
|
from utilities.serialization import serialize_object
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -752,3 +753,12 @@ def register_models(*models):
|
|||||||
register_model_view(model, 'sync', kwargs={'model': model})(
|
register_model_view(model, 'sync', kwargs={'model': model})(
|
||||||
'netbox.views.generic.ObjectSyncDataView'
|
'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)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ registry = Registry({
|
|||||||
'denormalized_fields': collections.defaultdict(list),
|
'denormalized_fields': collections.defaultdict(list),
|
||||||
'event_types': dict(),
|
'event_types': dict(),
|
||||||
'filtersets': dict(),
|
'filtersets': dict(),
|
||||||
|
'model_actions': collections.defaultdict(list),
|
||||||
'model_features': dict(),
|
'model_features': dict(),
|
||||||
'models': collections.defaultdict(set),
|
'models': collections.defaultdict(set),
|
||||||
'plugins': dict(),
|
'plugins': dict(),
|
||||||
|
|||||||
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js.map
vendored
8
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,10 +1,17 @@
|
|||||||
import { initClearField } from './clearField';
|
import { initClearField } from './clearField';
|
||||||
import { initFormElements } from './elements';
|
import { initFormElements } from './elements';
|
||||||
import { initFilterModifiers } from './filterModifiers';
|
import { initFilterModifiers } from './filterModifiers';
|
||||||
|
import { initRegisteredActions } from './registeredActions';
|
||||||
import { initSpeedSelector } from './speedSelector';
|
import { initSpeedSelector } from './speedSelector';
|
||||||
|
|
||||||
export function initForms(): void {
|
export function initForms(): void {
|
||||||
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
|
for (const func of [
|
||||||
|
initFormElements,
|
||||||
|
initSpeedSelector,
|
||||||
|
initFilterModifiers,
|
||||||
|
initClearField,
|
||||||
|
initRegisteredActions,
|
||||||
|
]) {
|
||||||
func();
|
func();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
netbox/project-static/src/forms/registeredActions.ts
Normal file
62
netbox/project-static/src/forms/registeredActions.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
netbox/templates/users/panels/actions.html
Normal file
26
netbox/templates/users/panels/actions.html
Normal file
@@ -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 %}
|
||||||
@@ -10,6 +10,11 @@ OBJECTPERMISSION_OBJECT_TYPES = (
|
|||||||
|
|
||||||
CONSTRAINT_TOKEN_USER = '$user'
|
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
|
# API tokens
|
||||||
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
|
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
|
||||||
TOKEN_KEY_LENGTH = 12
|
TOKEN_KEY_LENGTH = 12
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ipam.formfields import IPNetworkFormField
|
|||||||
from ipam.validators import prefix_validator
|
from ipam.validators import prefix_validator
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.preferences import PREFERENCES
|
from netbox.preferences import PREFERENCES
|
||||||
|
from netbox.registry import registry
|
||||||
from users.choices import TokenVersionChoices
|
from users.choices import TokenVersionChoices
|
||||||
from users.constants import *
|
from users.constants import *
|
||||||
from users.models import *
|
from users.models import *
|
||||||
@@ -25,8 +26,8 @@ from utilities.forms.fields import (
|
|||||||
JSONField,
|
JSONField,
|
||||||
)
|
)
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
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
|
from utilities.string import title
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -325,7 +326,7 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
object_types = ContentTypeMultipleChoiceField(
|
object_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Object types'),
|
label=_('Object types'),
|
||||||
queryset=ObjectType.objects.all(),
|
queryset=ObjectType.objects.all(),
|
||||||
widget=SplitMultiSelectWidget(
|
widget=ObjectTypeSplitMultiSelectWidget(
|
||||||
choices=get_object_types_choices
|
choices=get_object_types_choices
|
||||||
),
|
),
|
||||||
help_text=_('Select the types of objects to which the permission will apply.')
|
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(
|
can_delete = forms.BooleanField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
registered_actions = forms.MultipleChoiceField(
|
||||||
|
required=False,
|
||||||
|
widget=RegisteredActionsWidget(),
|
||||||
|
label=_('Registered actions'),
|
||||||
|
)
|
||||||
actions = SimpleArrayField(
|
actions = SimpleArrayField(
|
||||||
label=_('Additional actions'),
|
label=_('Additional actions'),
|
||||||
base_field=forms.CharField(),
|
base_field=forms.CharField(),
|
||||||
required=False,
|
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(
|
users = DynamicModelMultipleChoiceField(
|
||||||
label=_('Users'),
|
label=_('Users'),
|
||||||
@@ -370,8 +376,11 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('name', 'description', 'enabled'),
|
FieldSet('name', 'description', 'enabled'),
|
||||||
FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')),
|
|
||||||
FieldSet('object_types', name=_('Objects')),
|
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('groups', 'users', name=_('Assignment')),
|
||||||
FieldSet('constraints', name=_('Constraints')),
|
FieldSet('constraints', name=_('Constraints')),
|
||||||
)
|
)
|
||||||
@@ -385,6 +394,25 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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
|
# Make the actions field optional since the form uses it only for non-CRUD actions
|
||||||
self.fields['actions'].required = False
|
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['groups'].initial = self.instance.groups.values_list('id', flat=True)
|
||||||
self.fields['users'].initial = self.instance.users.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
|
# Work with a copy to avoid mutating the instance
|
||||||
for action in ['view', 'add', 'change', 'delete']:
|
remaining_actions = list(self.instance.actions)
|
||||||
if action in 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.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
|
# Populate initial data for a new ObjectPermission
|
||||||
elif self.initial:
|
elif self.initial:
|
||||||
@@ -408,7 +457,7 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
if isinstance(self.initial['actions'], str):
|
if isinstance(self.initial['actions'], str):
|
||||||
self.initial['actions'] = [self.initial['actions']]
|
self.initial['actions'] = [self.initial['actions']]
|
||||||
if cloned_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:
|
if action in cloned_actions:
|
||||||
self.fields[f'can_{action}'].initial = True
|
self.fields[f'can_{action}'].initial = True
|
||||||
self.initial['actions'].remove(action)
|
self.initial['actions'].remove(action)
|
||||||
@@ -420,15 +469,41 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
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')
|
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
|
# Append any of the selected CRUD checkboxes to the actions list
|
||||||
if not self.cleaned_data.get('actions'):
|
for action in RESERVED_ACTIONS:
|
||||||
self.cleaned_data['actions'] = list()
|
if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
|
||||||
for action in ['view', 'add', 'change', 'delete']:
|
final_actions.append(action)
|
||||||
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
|
|
||||||
self.cleaned_data['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
|
# At least one action must be specified
|
||||||
if not self.cleaned_data['actions']:
|
if not self.cleaned_data['actions']:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from netbox.registry import registry
|
||||||
from netbox.ui import actions, attrs, panels
|
from netbox.ui import actions, attrs, panels
|
||||||
|
from users.constants import RESERVED_ACTIONS
|
||||||
|
|
||||||
|
|
||||||
class TokenPanel(panels.ObjectAttributesPanel):
|
class TokenPanel(panels.ObjectAttributesPanel):
|
||||||
@@ -45,13 +47,43 @@ class ObjectPermissionPanel(panels.ObjectAttributesPanel):
|
|||||||
enabled = attrs.BooleanAttr('enabled')
|
enabled = attrs.BooleanAttr('enabled')
|
||||||
|
|
||||||
|
|
||||||
class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
|
class ObjectPermissionActionsPanel(panels.ObjectPanel):
|
||||||
|
template_name = 'users/panels/actions.html'
|
||||||
title = _('Actions')
|
title = _('Actions')
|
||||||
|
|
||||||
can_view = attrs.BooleanAttr('can_view', label=_('View'))
|
def get_context(self, context):
|
||||||
can_add = attrs.BooleanAttr('can_add', label=_('Add'))
|
obj = context['object']
|
||||||
can_change = attrs.BooleanAttr('can_change', label=_('Change'))
|
|
||||||
can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
|
crud_actions = [
|
||||||
|
(_('View'), 'view' in obj.actions),
|
||||||
|
(_('Add'), 'add' in obj.actions),
|
||||||
|
(_('Change'), 'change' in obj.actions),
|
||||||
|
(_('Delete'), 'delete' in obj.actions),
|
||||||
|
]
|
||||||
|
|
||||||
|
enabled_actions = set(obj.actions) - set(RESERVED_ACTIONS)
|
||||||
|
|
||||||
|
# Collect all registered actions from the full registry, deduplicating by name.
|
||||||
|
seen = []
|
||||||
|
seen_set = set()
|
||||||
|
action_models = {}
|
||||||
|
for model_key, model_actions in registry['model_actions'].items():
|
||||||
|
for action in model_actions:
|
||||||
|
if action.name not in seen_set:
|
||||||
|
seen.append(action.name)
|
||||||
|
seen_set.add(action.name)
|
||||||
|
action_models.setdefault(action.name, []).append(model_key)
|
||||||
|
|
||||||
|
registered_display = [
|
||||||
|
(action, action in enabled_actions, ', '.join(sorted(action_models[action])))
|
||||||
|
for action in seen
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
**super().get_context(context),
|
||||||
|
'crud_actions': crud_actions,
|
||||||
|
'registered_actions': registered_display,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class OwnerPanel(panels.ObjectAttributesPanel):
|
class OwnerPanel(panels.ObjectAttributesPanel):
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .actions import *
|
||||||
from .apiselect import *
|
from .apiselect import *
|
||||||
from .datetime import *
|
from .datetime import *
|
||||||
from .misc import *
|
from .misc import *
|
||||||
|
|||||||
47
netbox/utilities/forms/widgets/actions.py
Normal file
47
netbox/utilities/forms/widgets/actions.py
Normal 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(sorted(self._action_model_keys.get(action_name, set())))
|
||||||
|
option['help_text'] = self._action_help_text.get(action_name, '')
|
||||||
|
return option
|
||||||
@@ -9,6 +9,7 @@ __all__ = (
|
|||||||
'ClearableSelect',
|
'ClearableSelect',
|
||||||
'ColorSelect',
|
'ColorSelect',
|
||||||
'HTMXSelect',
|
'HTMXSelect',
|
||||||
|
'ObjectTypeSplitMultiSelectWidget',
|
||||||
'SelectWithPK',
|
'SelectWithPK',
|
||||||
'SplitMultiSelectWidget',
|
'SplitMultiSelectWidget',
|
||||||
)
|
)
|
||||||
@@ -150,14 +151,16 @@ class SplitMultiSelectWidget(forms.MultiWidget):
|
|||||||
be enabled only if the order of the selected choices is significant.
|
be enabled only if the order of the selected choices is significant.
|
||||||
"""
|
"""
|
||||||
template_name = 'widgets/splitmultiselect.html'
|
template_name = 'widgets/splitmultiselect.html'
|
||||||
|
available_widget_class = AvailableOptions
|
||||||
|
selected_widget_class = SelectedOptions
|
||||||
|
|
||||||
def __init__(self, choices, attrs=None, ordering=False):
|
def __init__(self, choices, attrs=None, ordering=False):
|
||||||
widgets = [
|
widgets = [
|
||||||
AvailableOptions(
|
self.available_widget_class(
|
||||||
attrs={'size': 8},
|
attrs={'size': 8},
|
||||||
choices=choices
|
choices=choices
|
||||||
),
|
),
|
||||||
SelectedOptions(
|
self.selected_widget_class(
|
||||||
attrs={'size': 8, 'class': 'select-all'},
|
attrs={'size': 8, 'class': 'select-all'},
|
||||||
choices=choices
|
choices=choices
|
||||||
),
|
),
|
||||||
@@ -180,3 +183,53 @@ class SplitMultiSelectWidget(forms.MultiWidget):
|
|||||||
def value_from_datadict(self, data, files, name):
|
def value_from_datadict(self, data, files, name):
|
||||||
# Return only the choices from the SelectedOptions widget
|
# Return only the choices from the SelectedOptions widget
|
||||||
return super().value_from_datadict(data, files, name)[1]
|
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
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
context = super().get_context(name, value, attrs)
|
||||||
|
context['widget']['attrs']['data-object-types-selected'] = 'true'
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,19 +1,84 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
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 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__ = (
|
__all__ = (
|
||||||
|
'ModelAction',
|
||||||
|
'get_action_model_map',
|
||||||
'get_permission_for_model',
|
'get_permission_for_model',
|
||||||
'permission_is_exempt',
|
'permission_is_exempt',
|
||||||
'qs_filter_from_constraints',
|
'qs_filter_from_constraints',
|
||||||
|
'register_model_actions',
|
||||||
'resolve_permission',
|
'resolve_permission',
|
||||||
'resolve_permission_type',
|
'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):
|
def get_permission_for_model(model, action):
|
||||||
"""
|
"""
|
||||||
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
|
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load i18n %}
|
{% 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 %}">
|
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
|
||||||
|
|
||||||
{# Render the field label (if any), except for checkboxes #}
|
{# Render the field label (if any), except for checkboxes #}
|
||||||
@@ -70,3 +83,4 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
19
netbox/utilities/templates/widgets/registered_actions.html
Normal file
19
netbox/utilities/templates/widgets/registered_actions.html
Normal 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 %}
|
||||||
185
netbox/utilities/tests/test_permissions.py
Normal file
185
netbox/utilities/tests/test_permissions.py
Normal 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)
|
||||||
@@ -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 = _('virtual machine')
|
||||||
verbose_name_plural = _('virtual machines')
|
verbose_name_plural = _('virtual machines')
|
||||||
|
permissions = [
|
||||||
|
('render_config', 'Render VM configuration'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
Reference in New Issue
Block a user