From 34b111bdc47253601994577886e28b5b7bf22549 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Sun, 14 Sep 2025 17:07:02 +0200 Subject: [PATCH] feat(users): Add support for cloning ObjectPermission objects Introduces cloning functionality for ObjectPermission objects using the CloningMixin. Updates the constraints field handling, adds JSONField, and introduces logic to process initial data for cloned objects. Fixes #15492 --- netbox/users/forms/model_forms.py | 46 ++++++++++++++++++++++-------- netbox/users/models/permissions.py | 7 ++++- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 505104c03..3e8c853a5 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -1,3 +1,5 @@ +import json + from django import forms from django.conf import settings from django.contrib.auth import password_validation @@ -13,7 +15,11 @@ from netbox.preferences import PREFERENCES from users.constants import * from users.models import * from utilities.data import flatten_dict -from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + ContentTypeMultipleChoiceField, + DynamicModelMultipleChoiceField, + JSONField, +) from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints @@ -316,13 +322,22 @@ class ObjectPermissionForm(forms.ModelForm): required=False, queryset=Group.objects.all() ) + constraints = JSONField( + required=False, + label=_('Constraints'), + help_text=_( + 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.' + ), + ) fieldsets = ( FieldSet('name', 'description', 'enabled'), FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')), FieldSet('object_types', name=_('Objects')), FieldSet('groups', 'users', name=_('Assignment')), - FieldSet('constraints', name=_('Constraints')) + FieldSet('constraints', name=_('Constraints')), ) class Meta: @@ -330,13 +345,6 @@ class ObjectPermissionForm(forms.ModelForm): fields = [ 'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions', ] - help_texts = { - 'constraints': _( - 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' - 'to match all objects of this type. A list of multiple objects will result in a logical OR ' - 'operation.' - ) - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -344,18 +352,32 @@ class ObjectPermissionForm(forms.ModelForm): # Make the actions field optional since the form uses it only for non-CRUD actions self.fields['actions'].required = False - # Populate assigned users and groups + # Prepare the appropriate fields when editing an existing ObjectPermission if self.instance.pk: + # Populate assigned users and groups 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 - if self.instance.pk: + # Check the appropriate checkboxes when editing an existing ObjectPermission for action in ['view', 'add', 'change', 'delete']: if action in self.instance.actions: self.fields[f'can_{action}'].initial = True self.instance.actions.remove(action) + # Populate initial data for a new ObjectPermission + elif self.initial: + # Handle cloned objects - actions come from initial data (URL parameters) + if 'actions' in self.initial: + if cloned_actions := self.initial['actions']: + for action in ['view', 'add', 'change', 'delete']: + if action in cloned_actions: + self.fields[f'can_{action}'].initial = True + self.initial['actions'].remove(action) + # Convert data delivered via initial data to JSON data + if 'constraints' in self.initial: + if type(self.initial['constraints']) is str: + self.initial['constraints'] = json.loads(self.initial['constraints']) + def clean(self): super().clean() diff --git a/netbox/users/models/permissions.py b/netbox/users/models/permissions.py index 3ae8ff4c1..3e41d4356 100644 --- a/netbox/users/models/permissions.py +++ b/netbox/users/models/permissions.py @@ -3,6 +3,7 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from netbox.models.features import CloningMixin from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -10,7 +11,7 @@ __all__ = ( ) -class ObjectPermission(models.Model): +class ObjectPermission(CloningMixin, models.Model): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. @@ -43,6 +44,10 @@ class ObjectPermission(models.Model): help_text=_("Queryset filter matching the applicable objects of the selected type(s)") ) + clone_fields = ( + 'description', 'enabled', 'object_types', 'actions', 'constraints', + ) + objects = RestrictedQuerySet.as_manager() class Meta: