mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-10 22:02:17 -06:00
* Fixes #20759: Group object types by app in permission form Modified the ObjectPermissionForm to use optgroups for organizing object types by application. This shortens the display names (e.g., "permission" instead of "Authentication and Authorization | permission") while maintaining clear organization through visual grouping. Changes: - Updated get_object_types_choices() to return nested optgroup structure - Enhanced AvailableOptions and SelectedOptions widgets to handle optgroups - Modified TypeScript moveOptions to preserve optgroup structure - Added hover text showing full model names - Styled optgroups with bold, padded labels * Address PR feedback
449 lines
16 KiB
Python
449 lines
16 KiB
Python
import json
|
|
from collections import defaultdict
|
|
|
|
from django import forms
|
|
from django.apps import apps
|
|
from django.conf import settings
|
|
from django.contrib.auth import password_validation
|
|
from django.contrib.postgres.forms import SimpleArrayField
|
|
from django.core.exceptions import FieldError
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from core.models import ObjectType
|
|
from ipam.formfields import IPNetworkFormField
|
|
from ipam.validators import prefix_validator
|
|
from netbox.config import get_config
|
|
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,
|
|
JSONField,
|
|
)
|
|
from utilities.string import title
|
|
from utilities.forms.rendering import FieldSet
|
|
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
|
from utilities.permissions import qs_filter_from_constraints
|
|
|
|
__all__ = (
|
|
'GroupForm',
|
|
'ObjectPermissionForm',
|
|
'TokenForm',
|
|
'UserConfigForm',
|
|
'UserForm',
|
|
'UserTokenForm',
|
|
'TokenForm',
|
|
)
|
|
|
|
|
|
class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
|
|
|
def __new__(mcs, name, bases, attrs):
|
|
|
|
# Emulate a declared field for each supported user preference
|
|
preference_fields = {}
|
|
for field_name, preference in PREFERENCES.items():
|
|
help_text = f'<code>{field_name}</code>'
|
|
if preference.description:
|
|
help_text = f'{preference.description}<br />{help_text}'
|
|
if warning := preference.warning:
|
|
help_text = f'<span class="text-danger"><i class="mdi mdi-alert"></i> {warning}</span><br />{help_text}'
|
|
field_kwargs = {
|
|
'label': preference.label,
|
|
'choices': preference.choices,
|
|
'help_text': mark_safe(help_text),
|
|
'coerce': preference.coerce,
|
|
'required': False,
|
|
'widget': forms.Select,
|
|
}
|
|
preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
|
|
attrs.update(preference_fields)
|
|
|
|
return super().__new__(mcs, name, bases, attrs)
|
|
|
|
|
|
class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
|
|
fieldsets = (
|
|
FieldSet(
|
|
'locale.language', 'ui.copilot_enabled', 'pagination.per_page', 'pagination.placement',
|
|
'ui.htmx_navigation', 'ui.tables.striping',
|
|
name=_('User Interface')
|
|
),
|
|
FieldSet('data_format', 'csv_delimiter', name=_('Miscellaneous')),
|
|
)
|
|
# List of clearable preferences
|
|
pk = forms.MultipleChoiceField(
|
|
choices=[],
|
|
required=False
|
|
)
|
|
|
|
class Meta:
|
|
model = UserConfig
|
|
fields = ()
|
|
|
|
def __init__(self, *args, instance=None, **kwargs):
|
|
|
|
# Get initial data from UserConfig instance
|
|
kwargs['initial'] = flatten_dict(instance.data)
|
|
|
|
super().__init__(*args, instance=instance, **kwargs)
|
|
|
|
# Compile clearable preference choices
|
|
self.fields['pk'].choices = (
|
|
(f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
|
|
)
|
|
|
|
# Disable Copilot preference if it has been disabled globally
|
|
if not get_config().COPILOT_ENABLED:
|
|
self.fields['ui.copilot_enabled'].disabled = True
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
# Set UserConfig data
|
|
for pref_name, value in self.cleaned_data.items():
|
|
if pref_name == 'pk':
|
|
continue
|
|
self.instance.set(pref_name, value, commit=False)
|
|
|
|
# Clear selected preferences
|
|
for preference in self.cleaned_data['pk']:
|
|
self.instance.clear(preference)
|
|
|
|
return super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def plugin_fields(self):
|
|
return [
|
|
name for name in self.fields.keys() if name.startswith('plugins.')
|
|
]
|
|
|
|
|
|
class UserTokenForm(forms.ModelForm):
|
|
key = forms.CharField(
|
|
label=_('Key'),
|
|
help_text=_(
|
|
'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
|
|
'submitting this form, as it may no longer be accessible once the token has been created.'
|
|
),
|
|
widget=forms.TextInput(
|
|
attrs={'data-clipboard': 'true'}
|
|
)
|
|
)
|
|
allowed_ips = SimpleArrayField(
|
|
base_field=IPNetworkFormField(validators=[prefix_validator]),
|
|
required=False,
|
|
label=_('Allowed IPs'),
|
|
help_text=_(
|
|
'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
|
|
'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'
|
|
),
|
|
)
|
|
|
|
class Meta:
|
|
model = Token
|
|
fields = [
|
|
'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
|
]
|
|
widgets = {
|
|
'expires': DateTimePicker(),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Omit the key field if token retrieval is not permitted
|
|
if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
|
|
del self.fields['key']
|
|
|
|
# Generate an initial random key if none has been specified
|
|
if not self.instance.pk and not self.initial.get('key'):
|
|
self.initial['key'] = Token.generate_key()
|
|
|
|
|
|
class TokenForm(UserTokenForm):
|
|
user = forms.ModelChoiceField(
|
|
queryset=User.objects.order_by('username'),
|
|
label=_('User')
|
|
)
|
|
|
|
class Meta:
|
|
model = Token
|
|
fields = [
|
|
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
|
]
|
|
widgets = {
|
|
'expires': DateTimePicker(),
|
|
}
|
|
|
|
|
|
class UserForm(forms.ModelForm):
|
|
password = forms.CharField(
|
|
label=_('Password'),
|
|
widget=forms.PasswordInput(),
|
|
required=True,
|
|
)
|
|
confirm_password = forms.CharField(
|
|
label=_('Confirm password'),
|
|
widget=forms.PasswordInput(),
|
|
required=True,
|
|
help_text=_("Enter the same password as before, for verification."),
|
|
)
|
|
groups = DynamicModelMultipleChoiceField(
|
|
label=_('Groups'),
|
|
required=False,
|
|
queryset=Group.objects.all()
|
|
)
|
|
object_permissions = DynamicModelMultipleChoiceField(
|
|
required=False,
|
|
label=_('Permissions'),
|
|
queryset=ObjectPermission.objects.all()
|
|
)
|
|
|
|
fieldsets = (
|
|
FieldSet('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email', name=_('User')),
|
|
FieldSet('groups', name=_('Groups')),
|
|
FieldSet('is_active', 'is_staff', 'is_superuser', name=_('Status')),
|
|
FieldSet('object_permissions', name=_('Permissions')),
|
|
)
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions',
|
|
'is_active', 'is_staff', 'is_superuser',
|
|
]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if self.instance.pk:
|
|
# Password fields are optional for existing Users
|
|
self.fields['password'].required = False
|
|
self.fields['confirm_password'].required = False
|
|
|
|
def save(self, *args, **kwargs):
|
|
instance = super().save(*args, **kwargs)
|
|
|
|
# On edit, check if we have to save the password
|
|
if self.cleaned_data.get('password'):
|
|
instance.set_password(self.cleaned_data.get('password'))
|
|
instance.save()
|
|
|
|
return instance
|
|
|
|
def clean(self):
|
|
|
|
# Check that password confirmation matches if password is set
|
|
if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']:
|
|
raise forms.ValidationError(_("Passwords do not match! Please check your input and try again."))
|
|
|
|
# Enforce password validation rules (if configured)
|
|
if self.cleaned_data['password']:
|
|
password_validation.validate_password(self.cleaned_data['password'], self.instance)
|
|
|
|
|
|
class GroupForm(forms.ModelForm):
|
|
users = DynamicModelMultipleChoiceField(
|
|
label=_('Users'),
|
|
required=False,
|
|
queryset=User.objects.all()
|
|
)
|
|
object_permissions = DynamicModelMultipleChoiceField(
|
|
required=False,
|
|
label=_('Permissions'),
|
|
queryset=ObjectPermission.objects.all()
|
|
)
|
|
|
|
fieldsets = (
|
|
FieldSet('name', 'description'),
|
|
FieldSet('users', name=_('Users')),
|
|
FieldSet('object_permissions', name=_('Permissions')),
|
|
)
|
|
|
|
class Meta:
|
|
model = Group
|
|
fields = [
|
|
'name', 'description', 'users', 'object_permissions',
|
|
]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Populate assigned users and permissions
|
|
if self.instance.pk:
|
|
self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
|
|
|
|
def save(self, *args, **kwargs):
|
|
instance = super().save(*args, **kwargs)
|
|
|
|
# Update assigned users
|
|
instance.users.set(self.cleaned_data['users'])
|
|
|
|
return instance
|
|
|
|
|
|
def get_object_types_choices():
|
|
"""
|
|
Generate choices for object types grouped by app label using optgroups.
|
|
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
|
|
"""
|
|
app_label_map = {
|
|
app_config.label: app_config.verbose_name
|
|
for app_config in apps.get_app_configs()
|
|
}
|
|
choices_by_app = defaultdict(list)
|
|
|
|
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
|
|
app_label = app_label_map.get(ot.app_label, ot.app_label)
|
|
|
|
model_class = ot.model_class()
|
|
model_name = model_class._meta.verbose_name if model_class else ot.model
|
|
choices_by_app[app_label].append((ot.pk, title(model_name)))
|
|
|
|
return list(choices_by_app.items())
|
|
|
|
|
|
class ObjectPermissionForm(forms.ModelForm):
|
|
object_types = ContentTypeMultipleChoiceField(
|
|
label=_('Object types'),
|
|
queryset=ObjectType.objects.all(),
|
|
widget=SplitMultiSelectWidget(
|
|
choices=get_object_types_choices
|
|
),
|
|
help_text=_('Select the types of objects to which the permission will appy.')
|
|
)
|
|
can_view = forms.BooleanField(
|
|
required=False
|
|
)
|
|
can_add = forms.BooleanField(
|
|
required=False
|
|
)
|
|
can_change = forms.BooleanField(
|
|
required=False
|
|
)
|
|
can_delete = forms.BooleanField(
|
|
required=False
|
|
)
|
|
actions = SimpleArrayField(
|
|
label=_('Additional actions'),
|
|
base_field=forms.CharField(),
|
|
required=False,
|
|
help_text=_('Actions granted in addition to those listed above')
|
|
)
|
|
users = DynamicModelMultipleChoiceField(
|
|
label=_('Users'),
|
|
required=False,
|
|
queryset=User.objects.all()
|
|
)
|
|
groups = DynamicModelMultipleChoiceField(
|
|
label=_('Groups'),
|
|
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')),
|
|
)
|
|
|
|
class Meta:
|
|
model = ObjectPermission
|
|
fields = [
|
|
'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions',
|
|
]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Make the actions field optional since the form uses it only for non-CRUD actions
|
|
self.fields['actions'].required = False
|
|
|
|
# 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
|
|
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:
|
|
# Normalize actions to a list of strings
|
|
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']:
|
|
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()
|
|
|
|
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)
|
|
|
|
# At least one action must be specified
|
|
if not self.cleaned_data['actions']:
|
|
raise forms.ValidationError(_("At least one action must be selected."))
|
|
|
|
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query
|
|
# returns anything; we just want to make sure the specified constraints are valid.
|
|
if object_types and constraints:
|
|
# Normalize the constraints to a list of dicts
|
|
if type(constraints) is not list:
|
|
constraints = [constraints]
|
|
for ct in object_types:
|
|
model = ct.model_class()
|
|
|
|
try:
|
|
tokens = {
|
|
CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
|
|
}
|
|
model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
|
|
except (FieldError, ValueError) as e:
|
|
raise forms.ValidationError({
|
|
'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e)
|
|
})
|
|
|
|
def save(self, *args, **kwargs):
|
|
instance = super().save(*args, **kwargs)
|
|
|
|
# Update assigned users and groups
|
|
instance.users.set(self.cleaned_data['users'])
|
|
instance.groups.set(self.cleaned_data['groups'])
|
|
|
|
return instance
|