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

1import json 

2from collections import defaultdict 

3 

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 _ 

11 

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 

34 

35__all__ = ( 

36 'GroupForm', 

37 'ObjectPermissionForm', 

38 'OwnerForm', 

39 'OwnerGroupForm', 

40 'TokenForm', 

41 'UserConfigForm', 

42 'UserForm', 

43 'UserTokenForm', 

44) 

45 

46 

47class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): 

48 

49 def __new__(mcs, name, bases, attrs): 

50 

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) 

69 

70 return super().__new__(mcs, name, bases, attrs) 

71 

72 

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 ) 

86 

87 class Meta: 

88 model = UserConfig 

89 fields = () 

90 

91 def __init__(self, *args, instance=None, **kwargs): 

92 

93 # Get initial data from UserConfig instance 

94 kwargs['initial'] = flatten_dict(instance.data) 

95 

96 super().__init__(*args, instance=instance, **kwargs) 

97 

98 # Compile clearable preference choices 

99 self.fields['pk'].choices = ( 

100 (f'tables.{table_name}', '') for table_name in instance.data.get('tables', []) 

101 ) 

102 

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 

106 

107 def save(self, *args, **kwargs): 

108 

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) 

114 

115 # Clear selected preferences 

116 for preference in self.cleaned_data['pk']: 

117 self.instance.clear(preference) 

118 

119 return super().save(*args, **kwargs) 

120 

121 @property 

122 def plugin_fields(self): 

123 return [ 

124 name for name in self.fields.keys() if name.startswith('plugins.') 

125 ] 

126 

127 

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 ) 

138 

139 class Meta: 

140 model = Token 

141 fields = [ 

142 'version', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', 

143 ] 

144 widgets = { 

145 'expires': DateTimePicker(), 

146 } 

147 

148 def __init__(self, *args, **kwargs): 

149 super().__init__(*args, **kwargs) 

150 

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 

155 

156 elif self.instance._state.adding: 

157 self.initial['version'] = TokenVersionChoices.V2 

158 

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 

170 

171 

172class TokenForm(UserTokenForm): 

173 user = forms.ModelChoiceField( 

174 queryset=User.objects.order_by('username'), 

175 label=_('User') 

176 ) 

177 

178 class Meta(UserTokenForm.Meta): 

179 fields = [ 

180 'version', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', 

181 ] 

182 

183 def __init__(self, *args, **kwargs): 

184 super().__init__(*args, **kwargs) 

185 

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 

189 

190 def clean(self): 

191 super().clean() 

192 

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 ) 

204 

205 return self.cleaned_data 

206 

207 

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 ) 

230 

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 ) 

237 

238 class Meta: 

239 model = User 

240 fields = [ 

241 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions', 

242 'is_active', 'is_superuser', 

243 ] 

244 

245 def __init__(self, *args, **kwargs): 

246 super().__init__(*args, **kwargs) 

247 

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 

252 

253 def save(self, *args, **kwargs): 

254 instance = super().save(*args, **kwargs) 

255 

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() 

260 

261 return instance 

262 

263 def clean(self): 

264 

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.")) 

268 

269 # Enforce password validation rules (if configured) 

270 if self.cleaned_data['password']: 

271 password_validation.validate_password(self.cleaned_data['password'], self.instance) 

272 

273 

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 ) 

285 

286 fieldsets = ( 

287 FieldSet('name', 'description'), 

288 FieldSet('users', name=_('Users')), 

289 FieldSet('object_permissions', name=_('Permissions')), 

290 ) 

291 

292 class Meta: 

293 model = Group 

294 fields = [ 

295 'name', 'description', 'users', 'object_permissions', 

296 ] 

297 

298 def __init__(self, *args, **kwargs): 

299 super().__init__(*args, **kwargs) 

300 

301 # Populate assigned users and permissions 

302 if self.instance.pk: 

303 self.fields['users'].initial = self.instance.users.values_list('id', flat=True) 

304 

305 def save(self, *args, **kwargs): 

306 instance = super().save(*args, **kwargs) 

307 

308 # Update assigned users 

309 instance.users.set(self.cleaned_data['users']) 

310 

311 return instance 

312 

313 

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) 

324 

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) 

327 

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))) 

331 

332 return list(choices_by_app.items()) 

333 

334 

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 ) 

381 

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 ) 

392 

393 class Meta: 

394 model = ObjectPermission 

395 fields = [ 

396 'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions', 

397 ] 

398 

399 def __init__(self, *args, **kwargs): 

400 super().__init__(*args, **kwargs) 

401 

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) 

409 

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) 

419 

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 ) 

434 

435 # Make the actions field optional since the form uses it only for non-CRUD actions 

436 self.fields['actions'].required = False 

437 

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) 

443 

444 # Work with a copy to avoid mutating the instance 

445 remaining_actions = list(self.instance.actions) 

446 

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) 

452 

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) 

458 

459 # Remaining actions go to the additional actions field 

460 self.initial['actions'] = remaining_actions 

461 

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']) 

483 

484 def clean(self): 

485 super().clean() 

486 

487 object_types = self.cleaned_data.get('object_types', []) 

488 constraints = self.cleaned_data.get('constraints') 

489 

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) 

497 

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) 

501 

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) 

506 

507 self.cleaned_data['actions'] = final_actions 

508 

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.")) 

512 

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() 

521 

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 }) 

531 

532 def save(self, *args, **kwargs): 

533 instance = super().save(*args, **kwargs) 

534 

535 # Update assigned users and groups 

536 instance.users.set(self.cleaned_data['users']) 

537 instance.groups.set(self.cleaned_data['groups']) 

538 

539 return instance 

540 

541 

542class OwnerGroupForm(forms.ModelForm): 

543 

544 fieldsets = ( 

545 FieldSet('name', 'description', name=_('Owner Group')), 

546 ) 

547 

548 class Meta: 

549 model = OwnerGroup 

550 fields = [ 

551 'name', 'description', 

552 ] 

553 

554 

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 ) 

578 

579 class Meta: 

580 model = Owner 

581 fields = [ 

582 'name', 'group', 'description', 'user_groups', 'users', 

583 ]