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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import json
2import re
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 _
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
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)
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()
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 )
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 }
135 def __init__(self, *args, **kwargs):
136 super().__init__(*args, **kwargs)
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 })
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
151 field_type = get_field_value(self, 'type')
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']
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']
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']
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']
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']
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 )
250 fieldsets = (
251 FieldSet(
252 'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically',
253 name=_('Custom Field Choice Set')
254 ),
255 )
257 class Meta:
258 model = CustomFieldChoiceSet
259 fields = (
260 'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 'owner'
261 )
263 def __init__(self, *args, initial=None, **kwargs):
264 super().__init__(*args, initial=initial, **kwargs)
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
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}')
286 self.initial['extra_choices'] = '\n'.join(choices)
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 {}
292 if isinstance(choice_colors, str):
293 choice_colors = json.loads(choice_colors)
295 mappings = []
296 for value, color in sorted(choice_colors.items()):
297 value = value.replace(':', '\\:')
298 mappings.append(f'{value}:{color}')
300 self.initial['choice_colors'] = '\n'.join(mappings)
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
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
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
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 )
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 )
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 }
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 )
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 )
396 class Meta:
397 model = ExportTemplate
398 fields = '__all__'
400 def __init__(self, *args, **kwargs):
401 super().__init__(*args, **kwargs)
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 )
410 def clean(self):
411 super().clean()
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"))
416 return self.cleaned_data
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()
427 fieldsets = (
428 FieldSet('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', name=_('Saved Filter')),
429 FieldSet('parameters', name=_('Parameters')),
430 )
432 class Meta:
433 model = SavedFilter
434 exclude = ('user',)
436 def __init__(self, *args, initial=None, **kwargs):
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'])
443 super().__init__(*args, initial=initial, **kwargs)
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 )
475 class Meta:
476 model = TableConfig
477 exclude = ('user',)
479 def __init__(self, data=None, *args, **kwargs):
480 super().__init__(data, *args, **kwargs)
482 self.fields['available_columns'].widget.choices = ()
483 self.fields['columns'].widget.choices = ()
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
491 try:
492 object_type = ObjectType.objects.get(pk=object_type_pk)
493 except (ObjectType.DoesNotExist, TypeError, ValueError):
494 return
496 model = object_type.model_class()
497 if model is None:
498 return
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
505 table = table_class([])
507 if columns := self._get_columns():
508 table._set_columns(columns)
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
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
525class BookmarkForm(forms.ModelForm):
526 object_type = ContentTypeChoiceField(
527 label=_('Object type'),
528 queryset=ObjectType.objects.with_feature('bookmarks')
529 )
531 class Meta:
532 model = Bookmark
533 fields = ('object_type', 'object_id')
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 )
548 class Meta:
549 model = NotificationGroup
550 fields = ('name', 'description', 'groups', 'users')
552 def clean(self):
553 super().clean()
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."))
559 return self.cleaned_data
562class SubscriptionForm(forms.ModelForm):
563 object_type = ContentTypeChoiceField(
564 label=_('Object type'),
565 queryset=ObjectType.objects.with_feature('notifications')
566 )
568 class Meta:
569 model = Subscription
570 fields = ('object_type', 'object_id')
573class WebhookForm(OwnerMixin, NetBoxModelForm):
574 http_method = ChoiceField(
575 label=_('HTTP method'),
576 choices=WebhookHttpMethodChoices,
577 initial=WebhookHttpMethodChoices.METHOD_POST,
578 )
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 )
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 }
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()
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 )
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 }
644 def init_action_choice(self):
645 action_type = get_field_value(self, 'action_type')
646 action = get_event_rule_action(action_type)
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
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()
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 )
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
672 self.init_action_choice()
674 def clean(self):
675 super().clean()
677 action = get_event_rule_action(self.cleaned_data.get('action_type'))
678 action_choice = self.cleaned_data.get('action_choice')
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
691 return self.cleaned_data
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 )
702 fieldsets = (
703 FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
704 )
706 class Meta:
707 model = Tag
708 fields = [
709 'name', 'slug', 'color', 'weight', 'description', 'object_types', 'owner',
710 ]
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 )
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 )
730 class Meta:
731 model = ConfigContextProfile
732 fields = (
733 'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'owner', 'comments',
734 'tags',
735 )
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 )
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 )
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 )
832 def __init__(self, *args, initial=None, **kwargs):
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'])
839 super().__init__(*args, initial=initial, **kwargs)
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.')
846 def clean(self):
847 super().clean()
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"))
852 return self.cleaned_data
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 )
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 )
876 class Meta:
877 model = ConfigTemplate
878 fields = '__all__'
879 widgets = {
880 'environment_params': forms.Textarea(attrs={'rows': 5})
881 }
883 def __init__(self, *args, **kwargs):
884 super().__init__(*args, **kwargs)
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 )
893 def clean(self):
894 super().clean()
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"))
899 return self.cleaned_data
902class ImageAttachmentForm(forms.ModelForm):
903 fieldsets = (
904 FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'),
905 )
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 }
920class JournalEntryForm(NetBoxModelForm):
921 kind = ChoiceField(
922 label=_('Kind'),
923 choices=JournalEntryKindChoices
924 )
925 comments = CommentField(required=True)
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 }