mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-10 11:23:58 +02:00
Compare commits
33 Commits
feature-me
...
21357-regi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c69463e4b | ||
|
|
822d0c3748 | ||
|
|
94612fd728 | ||
|
|
1ec9b5b135 | ||
|
|
2e19ee6961 | ||
|
|
fa50e0220c | ||
|
|
181c1abedd | ||
|
|
a57a538b92 | ||
|
|
b5839d5ac4 | ||
|
|
4c291f0463 | ||
|
|
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
|
||||
* **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 actions that appear as checkboxes in the "Actions" section when creating or editing a permission. These are shown in a flat list alongside the built-in CRUD actions. Additional actions (such as those not yet registered by a plugin, or for backwards compatibility) can be entered manually in the "Additional actions" field.
|
||||
|
||||
!!! note
|
||||
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
|
||||
|
||||
24
docs/plugins/development/permissions.md
Normal file
24
docs/plugins/development/permissions.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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'),
|
||||
]
|
||||
```
|
||||
|
||||
Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission.
|
||||
@@ -152,6 +152,7 @@ nav:
|
||||
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
||||
- Search: 'plugins/development/search.md'
|
||||
- Event Types: 'plugins/development/event-types.md'
|
||||
- Permissions: 'plugins/development/permissions.md'
|
||||
- Data Backends: 'plugins/development/data-backends.md'
|
||||
- Webhooks: 'plugins/development/webhooks.md'
|
||||
- User Interface: 'plugins/development/user-interface.md'
|
||||
|
||||
17
netbox/core/migrations/0022_datasource_sync_permission.py
Normal file
17
netbox/core/migrations/0022_datasource_sync_permission.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}'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-31 21:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0230_interface_rf_channel_frequency_precision'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='device',
|
||||
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
|
||||
),
|
||||
]
|
||||
@@ -759,6 +759,9 @@ class Device(
|
||||
)
|
||||
verbose_name = _('device')
|
||||
verbose_name_plural = _('devices')
|
||||
permissions = [
|
||||
('render_config', 'Render 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)
|
||||
|
||||
@@ -28,6 +28,7 @@ registry = Registry({
|
||||
'denormalized_fields': collections.defaultdict(list),
|
||||
'event_types': dict(),
|
||||
'filtersets': dict(),
|
||||
'model_actions': collections.defaultdict(set),
|
||||
'model_features': dict(),
|
||||
'models': collections.defaultdict(set),
|
||||
'plugins': dict(),
|
||||
|
||||
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'
|
||||
|
||||
# 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
|
||||
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
|
||||
TOKEN_KEY_LENGTH = 12
|
||||
|
||||
@@ -14,6 +14,7 @@ from ipam.formfields import IPNetworkFormField
|
||||
from ipam.validators import prefix_validator
|
||||
from netbox.config import get_config
|
||||
from netbox.preferences import PREFERENCES
|
||||
from netbox.registry import registry
|
||||
from users.choices import TokenVersionChoices
|
||||
from users.constants import *
|
||||
from users.models import *
|
||||
@@ -346,7 +347,7 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
label=_('Additional actions'),
|
||||
base_field=forms.CharField(),
|
||||
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(
|
||||
label=_('Users'),
|
||||
@@ -370,8 +371,11 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'description', 'enabled'),
|
||||
FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')),
|
||||
FieldSet('object_types', name=_('Objects')),
|
||||
FieldSet(
|
||||
'can_view', 'can_add', 'can_change', 'can_delete', 'actions',
|
||||
name=_('Actions')
|
||||
),
|
||||
FieldSet('groups', 'users', name=_('Assignment')),
|
||||
FieldSet('constraints', name=_('Constraints')),
|
||||
)
|
||||
@@ -385,6 +389,39 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Build dynamic BooleanFields for registered actions (deduplicated, sorted by name)
|
||||
seen = {}
|
||||
for model_actions in registry['model_actions'].values():
|
||||
for action in model_actions:
|
||||
if action.name not in seen:
|
||||
seen[action.name] = action
|
||||
registered_action_names = sorted(seen)
|
||||
|
||||
action_field_names = []
|
||||
for action_name in registered_action_names:
|
||||
field_name = f'action_{action_name}'
|
||||
self.fields[field_name] = forms.BooleanField(
|
||||
required=False,
|
||||
label=action_name,
|
||||
help_text=seen[action_name].help_text,
|
||||
)
|
||||
action_field_names.append(field_name)
|
||||
|
||||
# Rebuild the Actions fieldset to include dynamic fields
|
||||
if action_field_names:
|
||||
self.fieldsets = (
|
||||
FieldSet('name', 'description', 'enabled'),
|
||||
FieldSet('object_types', name=_('Objects')),
|
||||
FieldSet(
|
||||
'can_view', 'can_add', 'can_change', 'can_delete',
|
||||
*action_field_names,
|
||||
'actions',
|
||||
name=_('Actions')
|
||||
),
|
||||
FieldSet('groups', 'users', name=_('Assignment')),
|
||||
FieldSet('constraints', name=_('Constraints')),
|
||||
)
|
||||
|
||||
# Make the actions field optional since the form uses it only for non-CRUD actions
|
||||
self.fields['actions'].required = False
|
||||
|
||||
@@ -394,11 +431,23 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
self.fields['groups'].initial = self.instance.groups.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
|
||||
for action in ['view', 'add', 'change', 'delete']:
|
||||
if action in self.instance.actions:
|
||||
# Work with a copy to avoid mutating the instance
|
||||
remaining_actions = list(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.instance.actions.remove(action)
|
||||
remaining_actions.remove(action)
|
||||
|
||||
# Pre-select registered action checkboxes
|
||||
for action_name in registered_action_names:
|
||||
if action_name in remaining_actions:
|
||||
self.fields[f'action_{action_name}'].initial = True
|
||||
remaining_actions.remove(action_name)
|
||||
|
||||
# Remaining actions go to the additional actions field
|
||||
self.initial['actions'] = remaining_actions
|
||||
|
||||
# Populate initial data for a new ObjectPermission
|
||||
elif self.initial:
|
||||
@@ -408,10 +457,15 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
if isinstance(self.initial['actions'], str):
|
||||
self.initial['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:
|
||||
self.fields[f'can_{action}'].initial = True
|
||||
self.initial['actions'].remove(action)
|
||||
# Pre-select registered action checkboxes from cloned data
|
||||
for action_name in registered_action_names:
|
||||
if action_name in cloned_actions:
|
||||
self.fields[f'action_{action_name}'].initial = True
|
||||
self.initial['actions'].remove(action_name)
|
||||
# Convert data delivered via initial data to JSON data
|
||||
if 'constraints' in self.initial:
|
||||
if type(self.initial['constraints']) is str:
|
||||
@@ -420,15 +474,27 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
object_types = self.cleaned_data.get('object_types')
|
||||
object_types = self.cleaned_data.get('object_types', [])
|
||||
constraints = self.cleaned_data.get('constraints')
|
||||
|
||||
# Append any of the selected CRUD checkboxes to the actions list
|
||||
if not self.cleaned_data.get('actions'):
|
||||
self.cleaned_data['actions'] = list()
|
||||
for action in ['view', 'add', 'change', 'delete']:
|
||||
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
|
||||
self.cleaned_data['actions'].append(action)
|
||||
# Merge all actions: registered action checkboxes, CRUD checkboxes, and additional
|
||||
final_actions = []
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('action_') and value:
|
||||
action_name = key[7:]
|
||||
if action_name not in final_actions:
|
||||
final_actions.append(action_name)
|
||||
|
||||
for action in RESERVED_ACTIONS:
|
||||
if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
|
||||
final_actions.append(action)
|
||||
|
||||
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
|
||||
if not self.cleaned_data['actions']:
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.models.features import CloningMixin
|
||||
from netbox.registry import registry
|
||||
from users.constants import RESERVED_ACTIONS
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
@@ -82,5 +84,22 @@ class ObjectPermission(CloningMixin, models.Model):
|
||||
return [self.constraints]
|
||||
return self.constraints
|
||||
|
||||
def get_registered_actions(self):
|
||||
"""
|
||||
Return a list of (action_name, is_enabled, model_keys_csv) tuples for all
|
||||
registered actions, indicating which are enabled on this permission.
|
||||
"""
|
||||
enabled_actions = set(self.actions) - set(RESERVED_ACTIONS)
|
||||
|
||||
action_models = {}
|
||||
for model_key, model_actions in registry['model_actions'].items():
|
||||
for action in model_actions:
|
||||
action_models.setdefault(action.name, []).append(model_key)
|
||||
|
||||
return [
|
||||
(name, name in enabled_actions, ', '.join(sorted(action_models[name])))
|
||||
for name in sorted(action_models)
|
||||
]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('users:objectpermission', args=[self.pk])
|
||||
|
||||
@@ -45,13 +45,25 @@ class ObjectPermissionPanel(panels.ObjectAttributesPanel):
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
|
||||
|
||||
class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
|
||||
class ObjectPermissionActionsPanel(panels.ObjectPanel):
|
||||
template_name = 'users/panels/actions.html'
|
||||
title = _('Actions')
|
||||
|
||||
can_view = attrs.BooleanAttr('can_view', label=_('View'))
|
||||
can_add = attrs.BooleanAttr('can_add', label=_('Add'))
|
||||
can_change = attrs.BooleanAttr('can_change', label=_('Change'))
|
||||
can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
|
||||
def get_context(self, context):
|
||||
obj = context['object']
|
||||
|
||||
crud_actions = [
|
||||
(_('View'), 'view' in obj.actions),
|
||||
(_('Add'), 'add' in obj.actions),
|
||||
(_('Change'), 'change' in obj.actions),
|
||||
(_('Delete'), 'delete' in obj.actions),
|
||||
]
|
||||
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'crud_actions': crud_actions,
|
||||
'registered_actions': obj.get_registered_actions(),
|
||||
}
|
||||
|
||||
|
||||
class OwnerPanel(panels.ObjectAttributesPanel):
|
||||
|
||||
@@ -150,14 +150,16 @@ class SplitMultiSelectWidget(forms.MultiWidget):
|
||||
be enabled only if the order of the selected choices is significant.
|
||||
"""
|
||||
template_name = 'widgets/splitmultiselect.html'
|
||||
available_widget_class = AvailableOptions
|
||||
selected_widget_class = SelectedOptions
|
||||
|
||||
def __init__(self, choices, attrs=None, ordering=False):
|
||||
widgets = [
|
||||
AvailableOptions(
|
||||
self.available_widget_class(
|
||||
attrs={'size': 8},
|
||||
choices=choices
|
||||
),
|
||||
SelectedOptions(
|
||||
self.selected_widget_class(
|
||||
attrs={'size': 8, 'class': 'select-all'},
|
||||
choices=choices
|
||||
),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.apps import apps
|
||||
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 users.constants import CONSTRAINT_TOKEN_USER
|
||||
from netbox.registry import registry
|
||||
from users.constants import CONSTRAINT_TOKEN_USER, RESERVED_ACTIONS
|
||||
|
||||
__all__ = (
|
||||
'ModelAction',
|
||||
'get_permission_for_model',
|
||||
'permission_is_exempt',
|
||||
'qs_filter_from_constraints',
|
||||
@@ -14,6 +18,49 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@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 __post_init__(self):
|
||||
if not self.name:
|
||||
raise ValueError("Action name must not be empty.")
|
||||
if self.name in RESERVED_ACTIONS:
|
||||
raise ValueError(f"'{self.name}' is a reserved action and cannot be registered.")
|
||||
|
||||
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)
|
||||
registry['model_actions'][label].add(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).
|
||||
|
||||
199
netbox/utilities/tests/test_permissions.py
Normal file
199
netbox/utilities/tests/test_permissions.py
Normal file
@@ -0,0 +1,199 @@
|
||||
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)
|
||||
action = next(iter(actions))
|
||||
self.assertEqual(action.name, 'test_action')
|
||||
self.assertEqual(action.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)
|
||||
action_names = {a.name for a in actions}
|
||||
self.assertEqual(action_names, {'action1', 'action2'})
|
||||
self.assertTrue(all(isinstance(a, ModelAction) for a in actions))
|
||||
|
||||
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)
|
||||
actions_by_name = {a.name: a for a in actions}
|
||||
self.assertEqual(actions_by_name['with_help'].help_text, 'Has help')
|
||||
self.assertEqual(actions_by_name['without_help'].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)
|
||||
action_names = {a.name for a in actions}
|
||||
self.assertEqual(action_names, {'first', '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_no_duplicate_action_fields(self):
|
||||
register_model_actions(Device, [ModelAction('render_config')])
|
||||
register_model_actions(VirtualMachine, [ModelAction('render_config')])
|
||||
|
||||
form = ObjectPermissionForm()
|
||||
action_fields = [k for k in form.fields if k.startswith('action_')]
|
||||
self.assertEqual(action_fields.count('action_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):
|
||||
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)
|
||||
|
||||
self.assertTrue(form.fields['action_render_config'].initial)
|
||||
|
||||
self.assertEqual(form.initial['actions'], [])
|
||||
|
||||
permission.delete()
|
||||
|
||||
def test_clean_accepts_valid_registered_action(self):
|
||||
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],
|
||||
'action_render_config': True,
|
||||
'can_view': True,
|
||||
'actions': '',
|
||||
})
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
self.assertIn('render_config', form.cleaned_data['actions'])
|
||||
|
||||
def test_get_registered_actions(self):
|
||||
register_model_actions(Device, [ModelAction('render_config')])
|
||||
register_model_actions(VirtualMachine, [ModelAction('render_config')])
|
||||
|
||||
device_ct = ObjectType.objects.get_for_model(Device)
|
||||
|
||||
permission = ObjectPermission.objects.create(
|
||||
name='Test Registered Actions',
|
||||
actions=['view', 'render_config'],
|
||||
)
|
||||
permission.object_types.set([device_ct])
|
||||
|
||||
registered = permission.get_registered_actions()
|
||||
self.assertEqual(len(registered), 1)
|
||||
action_name, is_enabled, models_csv = registered[0]
|
||||
self.assertEqual(action_name, 'render_config')
|
||||
self.assertTrue(is_enabled)
|
||||
self.assertIn('dcim.device', models_csv)
|
||||
self.assertIn('virtualization.virtualmachine', models_csv)
|
||||
|
||||
permission.delete()
|
||||
|
||||
def test_form_with_no_registered_actions(self):
|
||||
device_ct = ObjectType.objects.get_for_model(Device)
|
||||
form = ObjectPermissionForm(data={
|
||||
'name': 'test perm',
|
||||
'object_types_0': [],
|
||||
'object_types_1': [device_ct.pk],
|
||||
'can_view': True,
|
||||
'actions': '',
|
||||
})
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
self.assertIn('view', form.cleaned_data['actions'])
|
||||
action_fields = [k for k in form.fields if k.startswith('action_')]
|
||||
self.assertEqual(action_fields, [])
|
||||
|
||||
def test_clone_preselects_registered_actions(self):
|
||||
register_model_actions(Device, [ModelAction('render_config')])
|
||||
|
||||
form = ObjectPermissionForm(initial={
|
||||
'actions': ['view', 'render_config'],
|
||||
})
|
||||
self.assertTrue(form.fields['action_render_config'].initial)
|
||||
self.assertNotIn('render_config', form.initial['actions'])
|
||||
@@ -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 configuration')]},
|
||||
),
|
||||
]
|
||||
@@ -276,6 +276,9 @@ class VirtualMachine(
|
||||
)
|
||||
verbose_name = _('virtual machine')
|
||||
verbose_name_plural = _('virtual machines')
|
||||
permissions = [
|
||||
('render_config', 'Render configuration'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
Reference in New Issue
Block a user