Coverage for extras/forms/model_forms.py: 43%

353 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import json 

2import re 

3 

4from django import forms 

5from django.contrib.postgres.forms import SimpleArrayField 

6from django.utils.safestring import mark_safe 

7from django.utils.translation import gettext_lazy as _ 

8 

9from core.forms.mixins import SyncedDataMixin 

10from core.models import ObjectType 

11from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup 

12from extras.choices import * 

13from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS 

14from extras.models import * 

15from netbox.event_rules import get_event_rule_action, get_event_rule_action_choices 

16from netbox.events import get_event_type_choices 

17from netbox.forms import NetBoxModelForm, PrimaryModelForm 

18from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin 

19from tenancy.models import Tenant, TenantGroup 

20from users.models import Group, User 

21from utilities.forms import add_blank_choice, get_field_value 

22from utilities.forms.fields import ( 

23 ChoiceField, 

24 CommentField, 

25 ContentTypeChoiceField, 

26 ContentTypeMultipleChoiceField, 

27 DynamicModelChoiceField, 

28 DynamicModelMultipleChoiceField, 

29 JSONField, 

30 MultipleChoiceField, 

31 SlugField, 

32 TypedChoiceField, 

33) 

34from utilities.forms.rendering import FieldSet, ObjectAttribute 

35from utilities.forms.widgets import ChoicesWidget, HTMXSelect 

36from utilities.tables import get_table_for_model 

37from virtualization.models import Cluster, ClusterGroup, ClusterType 

38 

39__all__ = ( 

40 'BookmarkForm', 

41 'ConfigContextForm', 

42 'ConfigContextProfileForm', 

43 'ConfigTemplateForm', 

44 'CustomFieldChoiceSetForm', 

45 'CustomFieldForm', 

46 'CustomLinkForm', 

47 'EventRuleForm', 

48 'ExportTemplateForm', 

49 'ImageAttachmentForm', 

50 'JournalEntryForm', 

51 'NotificationGroupForm', 

52 'SavedFilterForm', 

53 'SubscriptionForm', 

54 'TableConfigForm', 

55 'TagForm', 

56 'WebhookForm', 

57) 

58 

59 

60class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): 

61 type = ChoiceField( 

62 label=_('Type'), 

63 choices=CustomFieldTypeChoices, 

64 initial=CustomFieldTypeChoices.TYPE_TEXT, 

65 help_text=_( 

66 'The type of data stored in this field. For object/multi-object fields, select the related object ' 

67 'type below.' 

68 ), 

69 ) 

70 filter_logic = ChoiceField( 

71 label=_('Filter logic'), 

72 choices=CustomFieldFilterLogicChoices, 

73 initial=CustomFieldFilterLogicChoices.FILTER_LOOSE, 

74 help_text=_('Loose matches any instance of a given string; exact matches the entire field.'), 

75 ) 

76 ui_visible = ChoiceField( 

77 label=_('UI visible'), 

78 choices=CustomFieldUIVisibleChoices, 

79 initial=CustomFieldUIVisibleChoices.ALWAYS, 

80 help_text=_('Specifies whether the custom field is displayed in the UI'), 

81 ) 

82 ui_editable = ChoiceField( 

83 label=_('UI editable'), 

84 choices=CustomFieldUIEditableChoices, 

85 initial=CustomFieldUIEditableChoices.YES, 

86 help_text=_('Specifies whether the custom field value can be edited in the UI'), 

87 ) 

88 object_types = ContentTypeMultipleChoiceField( 

89 label=_('Object types'), 

90 queryset=ObjectType.objects.with_feature('custom_fields'), 

91 help_text=_("The type(s) of object that have this custom field") 

92 ) 

93 default = JSONField( 

94 label=_('Default value'), 

95 required=False 

96 ) 

97 related_object_type = ContentTypeChoiceField( 

98 label=_('Related object type'), 

99 queryset=ObjectType.objects.public(), 

100 help_text=_("Type of the related object (for object/multi-object fields only)") 

101 ) 

102 related_object_filter = JSONField( 

103 label=_('Related object filter'), 

104 required=False, 

105 help_text=_('Specify query parameters as a JSON object.') 

106 ) 

107 choice_set = DynamicModelChoiceField( 

108 queryset=CustomFieldChoiceSet.objects.all() 

109 ) 

110 validation_schema = JSONField( 

111 label=_('Validation schema'), 

112 required=False, 

113 help_text=_('A JSON schema definition for validating the custom field value') 

114 ) 

115 comments = CommentField() 

116 

117 fieldsets = ( 

118 FieldSet( 

119 'object_types', 'name', 'label', 'group_name', 'description', 'type', 'required', 'unique', 'default', 

120 name=_('Custom Field') 

121 ), 

122 FieldSet( 

123 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', 'nulls_first', 

124 name=_('Behavior') 

125 ), 

126 ) 

127 

128 class Meta: 

129 model = CustomField 

130 fields = '__all__' 

131 help_texts = { 

132 'description': _("This will be displayed as help text for the form field. Markdown is supported.") 

133 } 

134 

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

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

137 

138 # Mimic HTMXSelect() — no hx_target_id because changing type adds/removes 

139 # Validation, Related Object, and Choices fieldsets dynamically. 

140 self.fields['type'].widget.attrs.update({ 

141 'hx-get': '.', 

142 'hx-include': '#form_fields', 

143 'hx-target': '#form_fields', 

144 }) 

145 

146 # Disable changing the type of a CustomField as it almost universally causes errors if custom field data 

147 # is already present. 

148 if self.instance.pk: 

149 self.fields['type'].disabled = True 

150 

151 field_type = get_field_value(self, 'type') 

152 

153 # Adjust for text fields 

154 if field_type in ( 

155 CustomFieldTypeChoices.TYPE_TEXT, 

156 CustomFieldTypeChoices.TYPE_LONGTEXT, 

157 CustomFieldTypeChoices.TYPE_URL 

158 ): 

159 self.fieldsets = ( 

160 self.fieldsets[0], 

161 FieldSet('validation_regex', name=_('Validation')), 

162 *self.fieldsets[1:] 

163 ) 

164 else: 

165 del self.fields['validation_regex'] 

166 

167 # Adjust for numeric fields 

168 if field_type in ( 

169 CustomFieldTypeChoices.TYPE_INTEGER, 

170 CustomFieldTypeChoices.TYPE_DECIMAL 

171 ): 

172 self.fieldsets = ( 

173 self.fieldsets[0], 

174 FieldSet('validation_minimum', 'validation_maximum', name=_('Validation')), 

175 *self.fieldsets[1:] 

176 ) 

177 else: 

178 del self.fields['validation_minimum'] 

179 del self.fields['validation_maximum'] 

180 

181 # Adjust for JSON fields 

182 if field_type == CustomFieldTypeChoices.TYPE_JSON: 

183 self.fieldsets = ( 

184 self.fieldsets[0], 

185 FieldSet('validation_schema', name=_('Validation')), 

186 *self.fieldsets[1:] 

187 ) 

188 else: 

189 del self.fields['validation_schema'] 

190 

191 # Adjust for object & multi-object fields 

192 if field_type in ( 

193 CustomFieldTypeChoices.TYPE_OBJECT, 

194 CustomFieldTypeChoices.TYPE_MULTIOBJECT 

195 ): 

196 self.fieldsets = ( 

197 self.fieldsets[0], 

198 FieldSet('related_object_type', 'related_object_filter', name=_('Related Object')), 

199 *self.fieldsets[1:] 

200 ) 

201 else: 

202 del self.fields['related_object_type'] 

203 del self.fields['related_object_filter'] 

204 

205 # Adjust for selection & multi-select fields 

206 if field_type in ( 

207 CustomFieldTypeChoices.TYPE_SELECT, 

208 CustomFieldTypeChoices.TYPE_MULTISELECT 

209 ): 

210 self.fieldsets = ( 

211 self.fieldsets[0], 

212 FieldSet('choice_set', name=_('Choices')), 

213 *self.fieldsets[1:] 

214 ) 

215 else: 

216 del self.fields['choice_set'] 

217 

218 

219class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): 

220 base_choices = TypedChoiceField( 

221 label=_('Base choices'), 

222 choices=add_blank_choice(CustomFieldChoiceSetBaseChoices), 

223 required=False, 

224 help_text=_('Base set of predefined choices (optional)'), 

225 ) 

226 # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model 

227 extra_choices = forms.CharField( 

228 widget=ChoicesWidget(), 

229 required=False, 

230 help_text=mark_safe(_( 

231 'Enter one choice per line. An optional label may be specified for each choice by appending it with a ' 

232 'colon. Example:' 

233 ) + ' <code>choice1:First Choice</code>') 

234 ) 

235 choice_colors = forms.CharField( 

236 widget=ChoicesWidget(), 

237 required=False, 

238 help_text=mark_safe( 

239 _( 

240 'Bind an optional color to a choice by its value. Enter one mapping per line as ' 

241 '<code>value:color</code>. Example:' 

242 ) 

243 + ' <code>choice1:red</code><br />' 

244 + _('Supported colors: {colors}').format( 

245 colors=', '.join(f'<code>{color}</code>' for color in CustomFieldChoiceColorChoices.values()) 

246 ) 

247 ), 

248 ) 

249 

250 fieldsets = ( 

251 FieldSet( 

252 'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 

253 name=_('Custom Field Choice Set') 

254 ), 

255 ) 

256 

257 class Meta: 

258 model = CustomFieldChoiceSet 

259 fields = ( 

260 'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 'owner' 

261 ) 

262 

263 def __init__(self, *args, initial=None, **kwargs): 

264 super().__init__(*args, initial=initial, **kwargs) 

265 

266 # TODO: The check for str / list below is to handle difference in extra_choices field definition 

267 # In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField 

268 # if standardize these, we can simplify this code 

269 

270 # Convert extra_choices Array Field from model to CharField for form 

271 if extra_choices := self.initial.get('extra_choices', None): 

272 if isinstance(extra_choices, str): 

273 extra_choices = [extra_choices] 

274 choices = [] 

275 for choice in extra_choices: 

276 # Setup choices in Add Another use case 

277 if isinstance(choice, str): 

278 choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(",")) 

279 choices.append(choice_str) 

280 # Setup choices in Edit use case 

281 elif isinstance(choice, list): 

282 value = choice[0].replace(':', '\\:') 

283 label = choice[1].replace(':', '\\:') 

284 choices.append(f'{value}:{label}') 

285 

286 self.initial['extra_choices'] = '\n'.join(choices) 

287 

288 # Convert choice_colors JSONField from model to CharField for form 

289 if 'choice_colors' in self.initial: 

290 choice_colors = self.initial.get('choice_colors') or {} 

291 

292 if isinstance(choice_colors, str): 

293 choice_colors = json.loads(choice_colors) 

294 

295 mappings = [] 

296 for value, color in sorted(choice_colors.items()): 

297 value = value.replace(':', '\\:') 

298 mappings.append(f'{value}:{color}') 

299 

300 self.initial['choice_colors'] = '\n'.join(mappings) 

301 

302 def clean_extra_choices(self): 

303 data = [] 

304 for line in self.cleaned_data['extra_choices'].splitlines(): 

305 if not line.strip(): 

306 continue 

307 try: 

308 value, label = re.split(r'(?<!\\):', line, maxsplit=1) 

309 value = value.replace('\\:', ':') 

310 label = label.replace('\\:', ':') 

311 except ValueError: 

312 value, label = line, line 

313 data.append((value.strip(), label.strip())) 

314 return data 

315 

316 def clean_choice_colors(self): 

317 data = {} 

318 for line in self.cleaned_data['choice_colors'].splitlines(): 

319 if not line.strip(): 

320 continue 

321 try: 

322 value, color = re.split(r'(?<!\\):', line, maxsplit=1) 

323 value = value.replace('\\:', ':') 

324 except ValueError as e: 

325 raise forms.ValidationError( 

326 _("Invalid color mapping '{line}'. Use the format value:color.").format(line=line) 

327 ) from e 

328 

329 value = value.strip() 

330 color = color.strip() 

331 if value in data: 

332 raise forms.ValidationError( 

333 _("Duplicate color mapping defined for choice '{value}'.").format(value=value) 

334 ) 

335 data[value] = color 

336 return data 

337 

338 

339class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): 

340 button_class = ChoiceField( 

341 label=_('Button class'), 

342 choices=CustomLinkButtonClassChoices, 

343 initial=CustomLinkButtonClassChoices.DEFAULT, 

344 help_text=_('The class of the first link in a group will be used for the dropdown button'), 

345 ) 

346 object_types = ContentTypeMultipleChoiceField( 

347 label=_('Object types'), 

348 queryset=ObjectType.objects.with_feature('custom_links') 

349 ) 

350 

351 fieldsets = ( 

352 FieldSet( 

353 'name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window', 

354 name=_('Custom Link') 

355 ), 

356 FieldSet('link_text', 'link_url', name=_('Templates')), 

357 ) 

358 

359 class Meta: 

360 model = CustomLink 

361 fields = '__all__' 

362 widgets = { 

363 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), 

364 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), 

365 } 

366 help_texts = { 

367 'link_text': _( 

368 "Jinja2 template code for the link text. Reference the object as {example}. Links " 

369 "which render as empty text will not be displayed." 

370 ).format(example="<code>{{ object }}</code>"), 

371 'link_url': _( 

372 "Jinja2 template code for the link URL. Reference the object as {example}." 

373 ).format(example="<code>{{ object }}</code>"), 

374 } 

375 

376 

377class ExportTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm): 

378 object_types = ContentTypeMultipleChoiceField( 

379 label=_('Object types'), 

380 queryset=ObjectType.objects.with_feature('export_templates') 

381 ) 

382 template_code = forms.CharField( 

383 label=_('Template code'), 

384 required=False, 

385 widget=forms.Textarea(attrs={'class': 'font-monospace'}) 

386 ) 

387 

388 fieldsets = ( 

389 FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')), 

390 FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), 

391 FieldSet( 

392 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering') 

393 ), 

394 ) 

395 

396 class Meta: 

397 model = ExportTemplate 

398 fields = '__all__' 

399 

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

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

402 

403 # Disable data field when a DataFile has been set 

404 if self.instance.data_file: 

405 self.fields['template_code'].widget.attrs['readonly'] = True 

406 self.fields['template_code'].help_text = _( 

407 'Template content is populated from the remote source selected below.' 

408 ) 

409 

410 def clean(self): 

411 super().clean() 

412 

413 if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'): 

414 raise forms.ValidationError(_("Must specify either local content or a data file")) 

415 

416 return self.cleaned_data 

417 

418 

419class SavedFilterForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): 

420 slug = SlugField() 

421 object_types = ContentTypeMultipleChoiceField( 

422 label=_('Object types'), 

423 queryset=ObjectType.objects.all() 

424 ) 

425 parameters = JSONField() 

426 

427 fieldsets = ( 

428 FieldSet('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', name=_('Saved Filter')), 

429 FieldSet('parameters', name=_('Parameters')), 

430 ) 

431 

432 class Meta: 

433 model = SavedFilter 

434 exclude = ('user',) 

435 

436 def __init__(self, *args, initial=None, **kwargs): 

437 

438 # Convert any parameters delivered via initial data to JSON data 

439 if initial and 'parameters' in initial: 

440 if type(initial['parameters']) is str: 

441 initial['parameters'] = json.loads(initial['parameters']) 

442 

443 super().__init__(*args, initial=initial, **kwargs) 

444 

445 

446class TableConfigForm(ChangelogMessageMixin, forms.ModelForm): 

447 object_type = ContentTypeChoiceField( 

448 label=_('Object type'), 

449 queryset=ObjectType.objects.all() 

450 ) 

451 ordering = SimpleArrayField( 

452 base_field=forms.CharField(), 

453 required=False, 

454 label=_('Ordering'), 

455 help_text=_( 

456 "Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order." 

457 ) 

458 ) 

459 available_columns = SimpleArrayField( 

460 base_field=forms.CharField(), 

461 required=False, 

462 widget=forms.SelectMultiple( 

463 attrs={'size': 10, 'class': 'form-select'} 

464 ), 

465 label=_('Available Columns') 

466 ) 

467 columns = SimpleArrayField( 

468 base_field=forms.CharField(), 

469 widget=forms.SelectMultiple( 

470 attrs={'size': 10, 'class': 'form-select select-all'} 

471 ), 

472 label=_('Selected Columns') 

473 ) 

474 

475 class Meta: 

476 model = TableConfig 

477 exclude = ('user',) 

478 

479 def __init__(self, data=None, *args, **kwargs): 

480 super().__init__(data, *args, **kwargs) 

481 

482 self.fields['available_columns'].widget.choices = () 

483 self.fields['columns'].widget.choices = () 

484 

485 # Table context may be absent e.g. when the add view is requested directly 

486 object_type_pk = get_field_value(self, 'object_type') 

487 object_type_pk = getattr(object_type_pk, 'pk', object_type_pk) 

488 if not object_type_pk: 

489 return 

490 

491 try: 

492 object_type = ObjectType.objects.get(pk=object_type_pk) 

493 except (ObjectType.DoesNotExist, TypeError, ValueError): 

494 return 

495 

496 model = object_type.model_class() 

497 if model is None: 

498 return 

499 

500 table_name = get_field_value(self, 'table') 

501 table_class = get_table_for_model(model, table_name) 

502 if table_class is None: 

503 return 

504 

505 table = table_class([]) 

506 

507 if columns := self._get_columns(): 

508 table._set_columns(columns) 

509 

510 # Initialize columns field based on table attributes 

511 self.fields['available_columns'].widget.choices = table.available_columns 

512 self.fields['columns'].widget.choices = table.selected_columns 

513 

514 def _get_columns(self): 

515 if self.is_bound and (columns := self.data.getlist('columns')): 

516 return columns 

517 if 'columns' in self.initial: 

518 columns = self.get_initial_for_field(self.fields['columns'], 'columns') 

519 return columns.split(',') if type(columns) is str else columns 

520 if self.instance is not None: 

521 return self.instance.columns 

522 return None 

523 

524 

525class BookmarkForm(forms.ModelForm): 

526 object_type = ContentTypeChoiceField( 

527 label=_('Object type'), 

528 queryset=ObjectType.objects.with_feature('bookmarks') 

529 ) 

530 

531 class Meta: 

532 model = Bookmark 

533 fields = ('object_type', 'object_id') 

534 

535 

536class NotificationGroupForm(ChangelogMessageMixin, forms.ModelForm): 

537 groups = DynamicModelMultipleChoiceField( 

538 label=_('Groups'), 

539 required=False, 

540 queryset=Group.objects.all() 

541 ) 

542 users = DynamicModelMultipleChoiceField( 

543 label=_('Users'), 

544 required=False, 

545 queryset=User.objects.all() 

546 ) 

547 

548 class Meta: 

549 model = NotificationGroup 

550 fields = ('name', 'description', 'groups', 'users') 

551 

552 def clean(self): 

553 super().clean() 

554 

555 # At least one User or Group must be assigned 

556 if not self.cleaned_data['groups'] and not self.cleaned_data['users']: 

557 raise forms.ValidationError(_("A notification group specify at least one user or group.")) 

558 

559 return self.cleaned_data 

560 

561 

562class SubscriptionForm(forms.ModelForm): 

563 object_type = ContentTypeChoiceField( 

564 label=_('Object type'), 

565 queryset=ObjectType.objects.with_feature('notifications') 

566 ) 

567 

568 class Meta: 

569 model = Subscription 

570 fields = ('object_type', 'object_id') 

571 

572 

573class WebhookForm(OwnerMixin, NetBoxModelForm): 

574 http_method = ChoiceField( 

575 label=_('HTTP method'), 

576 choices=WebhookHttpMethodChoices, 

577 initial=WebhookHttpMethodChoices.METHOD_POST, 

578 ) 

579 

580 fieldsets = ( 

581 FieldSet('name', 'description', 'tags', name=_('Webhook')), 

582 FieldSet( 

583 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 

584 'timeout', name=_('HTTP Request') 

585 ), 

586 FieldSet('ssl_verification', 'ca_file_path', name=_('SSL')), 

587 ) 

588 

589 class Meta: 

590 model = Webhook 

591 fields = '__all__' 

592 widgets = { 

593 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), 

594 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), 

595 } 

596 

597 

598class EventRuleForm(OwnerMixin, NetBoxModelForm): 

599 action_type = ChoiceField( 

600 label=_('Action type'), 

601 choices=get_event_rule_action_choices, 

602 initial=EventRuleActionChoices.WEBHOOK, 

603 widget=HTMXSelect(hx_target_id='event-rule-action'), 

604 ) 

605 object_types = ContentTypeMultipleChoiceField( 

606 label=_('Object types'), 

607 queryset=ObjectType.objects.with_feature('event_rules'), 

608 ) 

609 event_types = MultipleChoiceField( 

610 choices=get_event_type_choices(), 

611 label=_('Event types') 

612 ) 

613 action_choice = ChoiceField( 

614 label=_('Action choice'), 

615 choices=[] 

616 ) 

617 conditions = JSONField( 

618 required=False, 

619 help_text=_('Enter conditions in <a href="https://json.org/">JSON</a> format.') 

620 ) 

621 action_data = JSONField( 

622 required=False, 

623 help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.') 

624 ) 

625 comments = CommentField() 

626 

627 fieldsets = ( 

628 FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), 

629 FieldSet('event_types', 'conditions', name=_('Triggers')), 

630 FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), html_id='event-rule-action'), 

631 ) 

632 

633 class Meta: 

634 model = EventRule 

635 fields = ( 

636 'object_types', 'name', 'description', 'enabled', 'event_types', 'conditions', 'action_type', 

637 'action_object_type', 'action_object_id', 'action_data', 'owner', 'comments', 'tags' 

638 ) 

639 widgets = { 

640 'action_object_type': forms.HiddenInput, 

641 'action_object_id': forms.HiddenInput, 

642 } 

643 

644 def init_action_choice(self): 

645 action_type = get_field_value(self, 'action_type') 

646 action = get_event_rule_action(action_type) 

647 

648 if action is None or action.object_model is None: 

649 # Either an unregistered action_type (e.g. the providing plugin is not installed), or 

650 # an action that doesn't operate on a target object at all -- no object picker needed. 

651 self.fields.pop('action_choice', None) 

652 return 

653 

654 initial = None 

655 if self.instance.action_type == action_type: 

656 object_id = get_field_value(self, 'action_object_id') 

657 if object_id: 

658 initial = action.get_object_queryset().filter(pk=object_id).first() 

659 

660 self.fields['action_choice'] = DynamicModelChoiceField( 

661 label=action.get_object_label(), 

662 queryset=action.get_object_queryset(), 

663 required=action.object_required, 

664 initial=initial, 

665 ) 

666 

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

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

669 self.fields['action_object_type'].required = False 

670 self.fields['action_object_id'].required = False 

671 

672 self.init_action_choice() 

673 

674 def clean(self): 

675 super().clean() 

676 

677 action = get_event_rule_action(self.cleaned_data.get('action_type')) 

678 action_choice = self.cleaned_data.get('action_choice') 

679 

680 if action and action.object_model and action_choice: 

681 self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model( 

682 action_choice, for_concrete_model=False 

683 ) 

684 self.cleaned_data['action_object_id'] = action_choice.pk 

685 elif action: 

686 # A no-object action, or an optional object left unselected: store no action_object 

687 self.cleaned_data['action_object_type'] = None 

688 self.cleaned_data['action_object_id'] = None 

689 # An unregistered action_type leaves the stored action_object untouched 

690 

691 return self.cleaned_data 

692 

693 

694class TagForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): 

695 slug = SlugField() 

696 object_types = ContentTypeMultipleChoiceField( 

697 label=_('Object types'), 

698 queryset=ObjectType.objects.with_feature('tags'), 

699 required=False 

700 ) 

701 

702 fieldsets = ( 

703 FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')), 

704 ) 

705 

706 class Meta: 

707 model = Tag 

708 fields = [ 

709 'name', 'slug', 'color', 'weight', 'description', 'object_types', 'owner', 

710 ] 

711 

712 

713class ConfigContextProfileForm(SyncedDataMixin, PrimaryModelForm): 

714 schema = JSONField( 

715 label=_('Schema'), 

716 required=False, 

717 help_text=_("Enter a valid JSON schema to define supported attributes.") 

718 ) 

719 tags = DynamicModelMultipleChoiceField( 

720 label=_('Tags'), 

721 queryset=Tag.objects.all(), 

722 required=False 

723 ) 

724 

725 fieldsets = ( 

726 FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')), 

727 FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), 

728 ) 

729 

730 class Meta: 

731 model = ConfigContextProfile 

732 fields = ( 

733 'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'owner', 'comments', 

734 'tags', 

735 ) 

736 

737 

738class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm): 

739 profile = DynamicModelChoiceField( 

740 label=_('Profile'), 

741 queryset=ConfigContextProfile.objects.all(), 

742 required=False 

743 ) 

744 regions = DynamicModelMultipleChoiceField( 

745 label=_('Regions'), 

746 queryset=Region.objects.all(), 

747 required=False 

748 ) 

749 site_groups = DynamicModelMultipleChoiceField( 

750 label=_('Site groups'), 

751 queryset=SiteGroup.objects.all(), 

752 required=False 

753 ) 

754 sites = DynamicModelMultipleChoiceField( 

755 label=_('Sites'), 

756 queryset=Site.objects.all(), 

757 required=False 

758 ) 

759 locations = DynamicModelMultipleChoiceField( 

760 label=_('Locations'), 

761 queryset=Location.objects.all(), 

762 required=False 

763 ) 

764 device_types = DynamicModelMultipleChoiceField( 

765 label=_('Device types'), 

766 queryset=DeviceType.objects.all(), 

767 required=False 

768 ) 

769 roles = DynamicModelMultipleChoiceField( 

770 label=_('Roles'), 

771 queryset=DeviceRole.objects.all(), 

772 required=False 

773 ) 

774 platforms = DynamicModelMultipleChoiceField( 

775 label=_('Platforms'), 

776 queryset=Platform.objects.all(), 

777 required=False 

778 ) 

779 cluster_types = DynamicModelMultipleChoiceField( 

780 label=_('Cluster types'), 

781 queryset=ClusterType.objects.all(), 

782 required=False 

783 ) 

784 cluster_groups = DynamicModelMultipleChoiceField( 

785 label=_('Cluster groups'), 

786 queryset=ClusterGroup.objects.all(), 

787 required=False 

788 ) 

789 clusters = DynamicModelMultipleChoiceField( 

790 label=_('Clusters'), 

791 queryset=Cluster.objects.all(), 

792 required=False 

793 ) 

794 tenant_groups = DynamicModelMultipleChoiceField( 

795 label=_('Tenant groups'), 

796 queryset=TenantGroup.objects.all(), 

797 required=False 

798 ) 

799 tenants = DynamicModelMultipleChoiceField( 

800 label=_('Tenants'), 

801 queryset=Tenant.objects.all(), 

802 required=False 

803 ) 

804 tags = DynamicModelMultipleChoiceField( 

805 label=_('Tags'), 

806 queryset=Tag.objects.all(), 

807 required=False 

808 ) 

809 data = JSONField( 

810 label=_('Data'), 

811 required=False 

812 ) 

813 

814 fieldsets = ( 

815 FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')), 

816 FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), 

817 FieldSet( 

818 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 

819 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 

820 name=_('Assignment') 

821 ), 

822 ) 

823 

824 class Meta: 

825 model = ConfigContext 

826 fields = ( 

827 'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 

828 'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 

829 'tenant_groups', 'tenants', 'owner', 'tags', 'data_source', 'data_file', 'auto_sync_enabled', 

830 ) 

831 

832 def __init__(self, *args, initial=None, **kwargs): 

833 

834 # Convert data delivered via initial data to JSON data 

835 if initial and 'data' in initial: 

836 if type(initial['data']) is str: 

837 initial['data'] = json.loads(initial['data']) 

838 

839 super().__init__(*args, initial=initial, **kwargs) 

840 

841 # Disable data field when a DataFile has been set 

842 if self.instance.data_file: 

843 self.fields['data'].widget.attrs['readonly'] = True 

844 self.fields['data'].help_text = _('Data is populated from the remote source selected below.') 

845 

846 def clean(self): 

847 super().clean() 

848 

849 if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'): 

850 raise forms.ValidationError(_("Must specify either local data or a data file")) 

851 

852 return self.cleaned_data 

853 

854 

855class ConfigTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm): 

856 tags = DynamicModelMultipleChoiceField( 

857 label=_('Tags'), 

858 queryset=Tag.objects.all(), 

859 required=False 

860 ) 

861 template_code = forms.CharField( 

862 label=_('Template code'), 

863 required=False, 

864 widget=forms.Textarea(attrs={'class': 'font-monospace'}) 

865 ) 

866 

867 fieldsets = ( 

868 FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')), 

869 FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), 

870 FieldSet( 

871 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', 'debug', 

872 name=_('Rendering') 

873 ), 

874 ) 

875 

876 class Meta: 

877 model = ConfigTemplate 

878 fields = '__all__' 

879 widgets = { 

880 'environment_params': forms.Textarea(attrs={'rows': 5}) 

881 } 

882 

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

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

885 

886 # Disable content field when a DataFile has been set 

887 if self.instance.data_file: 

888 self.fields['template_code'].widget.attrs['readonly'] = True 

889 self.fields['template_code'].help_text = _( 

890 'Template content is populated from the remote source selected below.' 

891 ) 

892 

893 def clean(self): 

894 super().clean() 

895 

896 if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'): 

897 raise forms.ValidationError(_("Must specify either local content or a data file")) 

898 

899 return self.cleaned_data 

900 

901 

902class ImageAttachmentForm(forms.ModelForm): 

903 fieldsets = ( 

904 FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'), 

905 ) 

906 

907 class Meta: 

908 model = ImageAttachment 

909 fields = [ 

910 'image', 'name', 'description', 

911 ] 

912 # Explicitly set 'image/avif' to support AVIF selection in Firefox 

913 widgets = { 

914 'image': forms.ClearableFileInput( 

915 attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))} 

916 ), 

917 } 

918 

919 

920class JournalEntryForm(NetBoxModelForm): 

921 kind = ChoiceField( 

922 label=_('Kind'), 

923 choices=JournalEntryKindChoices 

924 ) 

925 comments = CommentField(required=True) 

926 

927 class Meta: 

928 model = JournalEntry 

929 fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'tags', 'comments'] 

930 widgets = { 

931 'assigned_object_type': forms.HiddenInput, 

932 'assigned_object_id': forms.HiddenInput, 

933 }