Coverage for users/forms/model_forms.py: 33%
257 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import json
2from collections import defaultdict
4from django import forms
5from django.apps import apps
6from django.contrib.auth import password_validation
7from django.contrib.postgres.forms import SimpleArrayField
8from django.core.exceptions import FieldError
9from django.utils.safestring import mark_safe
10from django.utils.translation import gettext_lazy as _
12from core.models import ObjectType
13from ipam.formfields import IPNetworkFormField
14from ipam.validators import prefix_validator
15from netbox.config import get_config
16from netbox.preferences import PREFERENCES
17from netbox.registry import registry
18from users.choices import TokenVersionChoices
19from users.constants import *
20from users.models import *
21from users.utils import user_may_grant_token
22from utilities.choices import Choice
23from utilities.data import flatten_dict
24from utilities.forms.fields import (
25 ContentTypeMultipleChoiceField,
26 DynamicModelChoiceField,
27 DynamicModelMultipleChoiceField,
28 JSONField,
29)
30from utilities.forms.rendering import FieldSet
31from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
32from utilities.permissions import qs_filter_from_constraints
33from utilities.string import title
35__all__ = (
36 'GroupForm',
37 'ObjectPermissionForm',
38 'OwnerForm',
39 'OwnerGroupForm',
40 'TokenForm',
41 'UserConfigForm',
42 'UserForm',
43 'UserTokenForm',
44)
47class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
49 def __new__(mcs, name, bases, attrs):
51 # Emulate a declared field for each supported user preference
52 preference_fields = {}
53 for field_name, preference in PREFERENCES.items():
54 help_text = f'<code>{field_name}</code>'
55 if preference.description: 55 ↛ 57line 55 didn't jump to line 57 because the condition on line 55 was always true
56 help_text = f'{preference.description}<br />{help_text}'
57 if warning := preference.warning: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 help_text = f'<span class="text-danger"><i class="mdi mdi-alert"></i> {warning}</span><br />{help_text}'
59 field_kwargs = {
60 'label': preference.label,
61 'choices': preference.choices,
62 'help_text': mark_safe(help_text),
63 'coerce': preference.coerce,
64 'required': False,
65 'widget': forms.Select,
66 }
67 preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
68 attrs.update(preference_fields)
70 return super().__new__(mcs, name, bases, attrs)
73class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
74 fieldsets = (
75 FieldSet(
76 'locale.language', 'ui.copilot_enabled', 'pagination.per_page', 'pagination.placement',
77 'ui.tables.striping', 'ui.measurement_system', name=_('User Interface')
78 ),
79 FieldSet('data_format', 'csv_delimiter', name=_('Miscellaneous')),
80 )
81 # List of clearable preferences
82 pk = forms.MultipleChoiceField(
83 choices=[],
84 required=False
85 )
87 class Meta:
88 model = UserConfig
89 fields = ()
91 def __init__(self, *args, instance=None, **kwargs):
93 # Get initial data from UserConfig instance
94 kwargs['initial'] = flatten_dict(instance.data)
96 super().__init__(*args, instance=instance, **kwargs)
98 # Compile clearable preference choices
99 self.fields['pk'].choices = (
100 (f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
101 )
103 # Disable Copilot preference if it has been disabled globally
104 if not get_config().COPILOT_ENABLED:
105 self.fields['ui.copilot_enabled'].disabled = True
107 def save(self, *args, **kwargs):
109 # Set UserConfig data
110 for pref_name, value in self.cleaned_data.items():
111 if pref_name == 'pk':
112 continue
113 self.instance.set(pref_name, value, commit=False)
115 # Clear selected preferences
116 for preference in self.cleaned_data['pk']:
117 self.instance.clear(preference)
119 return super().save(*args, **kwargs)
121 @property
122 def plugin_fields(self):
123 return [
124 name for name in self.fields.keys() if name.startswith('plugins.')
125 ]
128class UserTokenForm(forms.ModelForm):
129 allowed_ips = SimpleArrayField(
130 base_field=IPNetworkFormField(validators=[prefix_validator]),
131 required=False,
132 label=_('Allowed IPs'),
133 help_text=_(
134 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
135 'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'
136 ),
137 )
139 class Meta:
140 model = Token
141 fields = [
142 'version', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips',
143 ]
144 widgets = {
145 'expires': DateTimePicker(),
146 }
148 def __init__(self, *args, **kwargs):
149 super().__init__(*args, **kwargs)
151 if self.instance.pk:
152 # Disable the version & user fields for existing Tokens
153 self.fields['version'].disabled = True
154 self.fields['user'].disabled = True
156 elif self.instance._state.adding:
157 self.initial['version'] = TokenVersionChoices.V2
159 def save(self, commit=True):
160 creating = self.instance.pk is None
161 instance = super().save(commit=commit)
162 # On creation, stash the auto-generated plaintext on the session so that the detail view can render the
163 # full HTTP authorization string exactly once. The plaintext is never persisted to the database; the
164 # request is delivered to the form via the edit view's alter_object hook.
165 if creating and instance._token and instance.pk is not None:
166 request = getattr(instance, '_request', None)
167 if request is not None:
168 request.session[f'_token_plaintext_{instance.pk}'] = instance._token
169 return instance
172class TokenForm(UserTokenForm):
173 user = forms.ModelChoiceField(
174 queryset=User.objects.order_by('username'),
175 label=_('User')
176 )
178 class Meta(UserTokenForm.Meta):
179 fields = [
180 'version', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips',
181 ]
183 def __init__(self, *args, **kwargs):
184 super().__init__(*args, **kwargs)
186 # If not creating a new Token, disable the user field
187 if self.instance and not self.instance._state.adding:
188 self.fields['user'].disabled = True
190 def clean(self):
191 super().clean()
193 # Creating a Token on behalf of another user requires the grant_token permission. This is enforced only on
194 # creation; the user field is disabled when editing an existing Token.
195 if self.instance._state.adding and (token_user := self.cleaned_data.get('user')):
196 request = getattr(self.instance, '_request', None)
197 if request is None:
198 # Fail closed: we cannot verify that the acting user is authorized.
199 raise forms.ValidationError(_("Unable to verify permission to create tokens."))
200 if token_user != request.user and not user_may_grant_token(request.user, token_user):
201 raise forms.ValidationError(
202 _("This user does not have permission to create tokens for other users.")
203 )
205 return self.cleaned_data
208class UserForm(forms.ModelForm):
209 password = forms.CharField(
210 label=_('Password'),
211 widget=forms.PasswordInput(),
212 required=True,
213 )
214 confirm_password = forms.CharField(
215 label=_('Confirm password'),
216 widget=forms.PasswordInput(),
217 required=True,
218 help_text=_("Enter the same password as before, for verification."),
219 )
220 groups = DynamicModelMultipleChoiceField(
221 label=_('Groups'),
222 required=False,
223 queryset=Group.objects.all()
224 )
225 object_permissions = DynamicModelMultipleChoiceField(
226 required=False,
227 label=_('Permissions'),
228 queryset=ObjectPermission.objects.all()
229 )
231 fieldsets = (
232 FieldSet('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email', name=_('User')),
233 FieldSet('groups', name=_('Groups')),
234 FieldSet('is_active', 'is_superuser', name=_('Status')),
235 FieldSet('object_permissions', name=_('Permissions')),
236 )
238 class Meta:
239 model = User
240 fields = [
241 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions',
242 'is_active', 'is_superuser',
243 ]
245 def __init__(self, *args, **kwargs):
246 super().__init__(*args, **kwargs)
248 if self.instance.pk:
249 # Password fields are optional for existing Users
250 self.fields['password'].required = False
251 self.fields['confirm_password'].required = False
253 def save(self, *args, **kwargs):
254 instance = super().save(*args, **kwargs)
256 # On edit, check if we have to save the password
257 if self.cleaned_data.get('password'):
258 instance.set_password(self.cleaned_data.get('password'))
259 instance.save()
261 return instance
263 def clean(self):
265 # Check that password confirmation matches if password is set
266 if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']:
267 raise forms.ValidationError(_("Passwords do not match! Please check your input and try again."))
269 # Enforce password validation rules (if configured)
270 if self.cleaned_data['password']:
271 password_validation.validate_password(self.cleaned_data['password'], self.instance)
274class GroupForm(forms.ModelForm):
275 users = DynamicModelMultipleChoiceField(
276 label=_('Users'),
277 required=False,
278 queryset=User.objects.all()
279 )
280 object_permissions = DynamicModelMultipleChoiceField(
281 required=False,
282 label=_('Permissions'),
283 queryset=ObjectPermission.objects.all()
284 )
286 fieldsets = (
287 FieldSet('name', 'description'),
288 FieldSet('users', name=_('Users')),
289 FieldSet('object_permissions', name=_('Permissions')),
290 )
292 class Meta:
293 model = Group
294 fields = [
295 'name', 'description', 'users', 'object_permissions',
296 ]
298 def __init__(self, *args, **kwargs):
299 super().__init__(*args, **kwargs)
301 # Populate assigned users and permissions
302 if self.instance.pk:
303 self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
305 def save(self, *args, **kwargs):
306 instance = super().save(*args, **kwargs)
308 # Update assigned users
309 instance.users.set(self.cleaned_data['users'])
311 return instance
314def get_object_types_choices():
315 """
316 Generate choices for object types grouped by app label using optgroups.
317 Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
318 """
319 app_label_map = {
320 app_config.label: app_config.verbose_name
321 for app_config in apps.get_app_configs()
322 }
323 choices_by_app = defaultdict(list)
325 for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
326 app_label = app_label_map.get(ot.app_label, ot.app_label)
328 model_class = ot.model_class()
329 model_name = model_class._meta.verbose_name if model_class else ot.model
330 choices_by_app[app_label].append(Choice(ot.pk, title(model_name)))
332 return list(choices_by_app.items())
335class ObjectPermissionForm(forms.ModelForm):
336 object_types = ContentTypeMultipleChoiceField(
337 label=_('Object types'),
338 queryset=ObjectType.objects.all(),
339 widget=SplitMultiSelectWidget(
340 choices=get_object_types_choices
341 ),
342 help_text=_('Select the types of objects to which the permission will apply.')
343 )
344 can_view = forms.BooleanField(
345 required=False
346 )
347 can_add = forms.BooleanField(
348 required=False
349 )
350 can_change = forms.BooleanField(
351 required=False
352 )
353 can_delete = forms.BooleanField(
354 required=False
355 )
356 actions = SimpleArrayField(
357 label=_('Additional actions'),
358 base_field=forms.CharField(),
359 required=False,
360 help_text=_('Additional actions for models which have not yet registered their own actions')
361 )
362 users = DynamicModelMultipleChoiceField(
363 label=_('Users'),
364 required=False,
365 queryset=User.objects.all()
366 )
367 groups = DynamicModelMultipleChoiceField(
368 label=_('Groups'),
369 required=False,
370 queryset=Group.objects.all()
371 )
372 constraints = JSONField(
373 required=False,
374 label=_('Constraints'),
375 help_text=_(
376 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
377 'to match all objects of this type. A list of multiple objects will result in a logical OR '
378 'operation.'
379 ),
380 )
382 fieldsets = (
383 FieldSet('name', 'description', 'enabled'),
384 FieldSet('object_types', name=_('Objects')),
385 FieldSet(
386 'can_view', 'can_add', 'can_change', 'can_delete', 'actions',
387 name=_('Actions')
388 ),
389 FieldSet('groups', 'users', name=_('Assignment')),
390 FieldSet('constraints', name=_('Constraints')),
391 )
393 class Meta:
394 model = ObjectPermission
395 fields = [
396 'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions',
397 ]
399 def __init__(self, *args, **kwargs):
400 super().__init__(*args, **kwargs)
402 # Build dynamic BooleanFields for registered actions (deduplicated, sorted by name)
403 seen = {}
404 for model_actions in registry['model_actions'].values():
405 for action in model_actions:
406 if action.name not in seen:
407 seen[action.name] = action
408 registered_action_names = sorted(seen)
410 action_field_names = []
411 for action_name in registered_action_names:
412 field_name = f'action_{action_name}'
413 self.fields[field_name] = forms.BooleanField(
414 required=False,
415 label=action_name,
416 help_text=seen[action_name].help_text,
417 )
418 action_field_names.append(field_name)
420 # Rebuild the Actions fieldset to include dynamic fields
421 if action_field_names:
422 self.fieldsets = (
423 FieldSet('name', 'description', 'enabled'),
424 FieldSet('object_types', name=_('Objects')),
425 FieldSet(
426 'can_view', 'can_add', 'can_change', 'can_delete',
427 *action_field_names,
428 'actions',
429 name=_('Actions')
430 ),
431 FieldSet('groups', 'users', name=_('Assignment')),
432 FieldSet('constraints', name=_('Constraints')),
433 )
435 # Make the actions field optional since the form uses it only for non-CRUD actions
436 self.fields['actions'].required = False
438 # Prepare the appropriate fields when editing an existing ObjectPermission
439 if self.instance.pk:
440 # Populate assigned users and groups
441 self.fields['groups'].initial = self.instance.groups.values_list('id', flat=True)
442 self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
444 # Work with a copy to avoid mutating the instance
445 remaining_actions = list(self.instance.actions)
447 # Check the appropriate CRUD checkboxes
448 for action in RESERVED_ACTIONS:
449 if action in remaining_actions:
450 self.fields[f'can_{action}'].initial = True
451 remaining_actions.remove(action)
453 # Pre-select registered action checkboxes
454 for action_name in registered_action_names:
455 if action_name in remaining_actions:
456 self.fields[f'action_{action_name}'].initial = True
457 remaining_actions.remove(action_name)
459 # Remaining actions go to the additional actions field
460 self.initial['actions'] = remaining_actions
462 # Populate initial data for a new ObjectPermission
463 elif self.initial:
464 # Handle cloned objects - actions come from initial data (URL parameters)
465 if 'actions' in self.initial:
466 # Normalize actions to a list of strings
467 if isinstance(self.initial['actions'], str):
468 self.initial['actions'] = [self.initial['actions']]
469 if cloned_actions := self.initial['actions']:
470 for action in RESERVED_ACTIONS:
471 if action in cloned_actions:
472 self.fields[f'can_{action}'].initial = True
473 self.initial['actions'].remove(action)
474 # Pre-select registered action checkboxes from cloned data
475 for action_name in registered_action_names:
476 if action_name in cloned_actions:
477 self.fields[f'action_{action_name}'].initial = True
478 self.initial['actions'].remove(action_name)
479 # Convert data delivered via initial data to JSON data
480 if 'constraints' in self.initial:
481 if type(self.initial['constraints']) is str:
482 self.initial['constraints'] = json.loads(self.initial['constraints'])
484 def clean(self):
485 super().clean()
487 object_types = self.cleaned_data.get('object_types', [])
488 constraints = self.cleaned_data.get('constraints')
490 # Merge all actions: registered action checkboxes, CRUD checkboxes, and additional
491 final_actions = []
492 for key, value in self.cleaned_data.items():
493 if key.startswith('action_') and value:
494 action_name = key[7:]
495 if action_name not in final_actions:
496 final_actions.append(action_name)
498 for action in RESERVED_ACTIONS:
499 if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
500 final_actions.append(action)
502 if additional_actions := self.cleaned_data.get('actions'):
503 for action in additional_actions:
504 if action not in final_actions:
505 final_actions.append(action)
507 self.cleaned_data['actions'] = final_actions
509 # At least one action must be specified
510 if not self.cleaned_data['actions']:
511 raise forms.ValidationError(_("At least one action must be selected."))
513 # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
514 # returns anything; we just want to make sure the specified constraints are valid.
515 if object_types and constraints:
516 # Normalize the constraints to a list of dicts
517 if type(constraints) is not list:
518 constraints = [constraints]
519 for ct in object_types:
520 model = ct.model_class()
522 try:
523 tokens = {
524 CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
525 }
526 model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
527 except (FieldError, ValueError) as e:
528 raise forms.ValidationError({
529 'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e)
530 })
532 def save(self, *args, **kwargs):
533 instance = super().save(*args, **kwargs)
535 # Update assigned users and groups
536 instance.users.set(self.cleaned_data['users'])
537 instance.groups.set(self.cleaned_data['groups'])
539 return instance
542class OwnerGroupForm(forms.ModelForm):
544 fieldsets = (
545 FieldSet('name', 'description', name=_('Owner Group')),
546 )
548 class Meta:
549 model = OwnerGroup
550 fields = [
551 'name', 'description',
552 ]
555class OwnerForm(forms.ModelForm):
556 fieldsets = (
557 FieldSet('name', 'group', 'description', name=_('Owner')),
558 FieldSet('user_groups', name=_('Groups')),
559 FieldSet('users', name=_('Users')),
560 )
561 group = DynamicModelChoiceField(
562 label=_('Group'),
563 queryset=OwnerGroup.objects.all(),
564 required=False,
565 selector=True,
566 quick_add=True
567 )
568 user_groups = DynamicModelMultipleChoiceField(
569 label=_('User groups'),
570 queryset=Group.objects.all(),
571 required=False
572 )
573 users = DynamicModelMultipleChoiceField(
574 label=_('Users'),
575 queryset=User.objects.all(),
576 required=False
577 )
579 class Meta:
580 model = Owner
581 fields = [
582 'name', 'group', 'description', 'user_groups', 'users',
583 ]