model_forms.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
  1. import json
  2. import re
  3. from django import forms
  4. from django.contrib.postgres.forms import SimpleArrayField
  5. from django.utils.safestring import mark_safe
  6. from django.utils.translation import gettext_lazy as _
  7. from core.forms.mixins import SyncedDataMixin
  8. from core.models import ObjectType
  9. from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
  10. from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
  11. from extras.choices import *
  12. from extras.models import *
  13. from netbox.events import get_event_type_choices
  14. from netbox.forms import NetBoxModelForm, PrimaryModelForm
  15. from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
  16. from tenancy.models import Tenant, TenantGroup
  17. from users.models import Group, User
  18. from utilities.forms import get_field_value
  19. from utilities.forms.fields import (
  20. CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
  21. DynamicModelMultipleChoiceField, JSONField, SlugField,
  22. )
  23. from utilities.forms.rendering import FieldSet, ObjectAttribute
  24. from utilities.forms.widgets import ChoicesWidget, HTMXSelect
  25. from utilities.tables import get_table_for_model
  26. from virtualization.models import Cluster, ClusterGroup, ClusterType
  27. __all__ = (
  28. 'BookmarkForm',
  29. 'ConfigContextForm',
  30. 'ConfigContextProfileForm',
  31. 'ConfigTemplateForm',
  32. 'CustomFieldChoiceSetForm',
  33. 'CustomFieldForm',
  34. 'CustomLinkForm',
  35. 'EventRuleForm',
  36. 'ExportTemplateForm',
  37. 'ImageAttachmentForm',
  38. 'JournalEntryForm',
  39. 'NotificationGroupForm',
  40. 'SavedFilterForm',
  41. 'SubscriptionForm',
  42. 'TableConfigForm',
  43. 'TagForm',
  44. 'WebhookForm',
  45. )
  46. class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  47. object_types = ContentTypeMultipleChoiceField(
  48. label=_('Object types'),
  49. queryset=ObjectType.objects.with_feature('custom_fields'),
  50. help_text=_("The type(s) of object that have this custom field")
  51. )
  52. default = JSONField(
  53. label=_('Default value'),
  54. required=False
  55. )
  56. related_object_type = ContentTypeChoiceField(
  57. label=_('Related object type'),
  58. queryset=ObjectType.objects.public(),
  59. help_text=_("Type of the related object (for object/multi-object fields only)")
  60. )
  61. related_object_filter = JSONField(
  62. label=_('Related object filter'),
  63. required=False,
  64. help_text=_('Specify query parameters as a JSON object.')
  65. )
  66. choice_set = DynamicModelChoiceField(
  67. queryset=CustomFieldChoiceSet.objects.all()
  68. )
  69. comments = CommentField()
  70. fieldsets = (
  71. FieldSet(
  72. 'object_types', 'name', 'label', 'group_name', 'description', 'type', 'required', 'unique', 'default',
  73. name=_('Custom Field')
  74. ),
  75. FieldSet(
  76. 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
  77. ),
  78. )
  79. class Meta:
  80. model = CustomField
  81. fields = '__all__'
  82. help_texts = {
  83. 'type': _(
  84. "The type of data stored in this field. For object/multi-object fields, select the related object "
  85. "type below."
  86. ),
  87. 'description': _("This will be displayed as help text for the form field. Markdown is supported.")
  88. }
  89. def __init__(self, *args, **kwargs):
  90. super().__init__(*args, **kwargs)
  91. # Mimic HTMXSelect()
  92. self.fields['type'].widget.attrs.update({
  93. 'hx-get': '.',
  94. 'hx-include': '#form_fields',
  95. 'hx-target': '#form_fields',
  96. })
  97. # Disable changing the type of a CustomField as it almost universally causes errors if custom field data
  98. # is already present.
  99. if self.instance.pk:
  100. self.fields['type'].disabled = True
  101. field_type = get_field_value(self, 'type')
  102. # Adjust for text fields
  103. if field_type in (
  104. CustomFieldTypeChoices.TYPE_TEXT,
  105. CustomFieldTypeChoices.TYPE_LONGTEXT,
  106. CustomFieldTypeChoices.TYPE_URL
  107. ):
  108. self.fieldsets = (
  109. self.fieldsets[0],
  110. FieldSet('validation_regex', name=_('Validation')),
  111. *self.fieldsets[1:]
  112. )
  113. else:
  114. del self.fields['validation_regex']
  115. # Adjust for numeric fields
  116. if field_type in (
  117. CustomFieldTypeChoices.TYPE_INTEGER,
  118. CustomFieldTypeChoices.TYPE_DECIMAL
  119. ):
  120. self.fieldsets = (
  121. self.fieldsets[0],
  122. FieldSet('validation_minimum', 'validation_maximum', name=_('Validation')),
  123. *self.fieldsets[1:]
  124. )
  125. else:
  126. del self.fields['validation_minimum']
  127. del self.fields['validation_maximum']
  128. # Adjust for object & multi-object fields
  129. if field_type in (
  130. CustomFieldTypeChoices.TYPE_OBJECT,
  131. CustomFieldTypeChoices.TYPE_MULTIOBJECT
  132. ):
  133. self.fieldsets = (
  134. self.fieldsets[0],
  135. FieldSet('related_object_type', 'related_object_filter', name=_('Related Object')),
  136. *self.fieldsets[1:]
  137. )
  138. else:
  139. del self.fields['related_object_type']
  140. del self.fields['related_object_filter']
  141. # Adjust for selection & multi-select fields
  142. if field_type in (
  143. CustomFieldTypeChoices.TYPE_SELECT,
  144. CustomFieldTypeChoices.TYPE_MULTISELECT
  145. ):
  146. self.fieldsets = (
  147. self.fieldsets[0],
  148. FieldSet('choice_set', name=_('Choices')),
  149. *self.fieldsets[1:]
  150. )
  151. else:
  152. del self.fields['choice_set']
  153. class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  154. # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
  155. extra_choices = forms.CharField(
  156. widget=ChoicesWidget(),
  157. required=False,
  158. help_text=mark_safe(_(
  159. 'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
  160. 'colon. Example:'
  161. ) + ' <code>choice1:First Choice</code>')
  162. )
  163. class Meta:
  164. model = CustomFieldChoiceSet
  165. fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
  166. def __init__(self, *args, initial=None, **kwargs):
  167. super().__init__(*args, initial=initial, **kwargs)
  168. # TODO: The check for str / list below is to handle difference in extra_choices field definition
  169. # In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
  170. # if standardize these, we can simplify this code
  171. # Convert extra_choices Array Field from model to CharField for form
  172. if extra_choices := self.initial.get('extra_choices', None):
  173. if isinstance(extra_choices, str):
  174. extra_choices = [extra_choices]
  175. choices = []
  176. for choice in extra_choices:
  177. # Setup choices in Add Another use case
  178. if isinstance(choice, str):
  179. choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
  180. choices.append(choice_str)
  181. # Setup choices in Edit use case
  182. elif isinstance(choice, list):
  183. value = choice[0].replace(':', '\\:')
  184. label = choice[1].replace(':', '\\:')
  185. choices.append(f'{value}:{label}')
  186. self.initial['extra_choices'] = '\n'.join(choices)
  187. def clean_extra_choices(self):
  188. data = []
  189. for line in self.cleaned_data['extra_choices'].splitlines():
  190. try:
  191. value, label = re.split(r'(?<!\\):', line, maxsplit=1)
  192. value = value.replace('\\:', ':')
  193. label = label.replace('\\:', ':')
  194. except ValueError:
  195. value, label = line, line
  196. data.append((value.strip(), label.strip()))
  197. return data
  198. class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  199. object_types = ContentTypeMultipleChoiceField(
  200. label=_('Object types'),
  201. queryset=ObjectType.objects.with_feature('custom_links')
  202. )
  203. fieldsets = (
  204. FieldSet(
  205. 'name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window',
  206. name=_('Custom Link')
  207. ),
  208. FieldSet('link_text', 'link_url', name=_('Templates')),
  209. )
  210. class Meta:
  211. model = CustomLink
  212. fields = '__all__'
  213. widgets = {
  214. 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
  215. 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
  216. }
  217. help_texts = {
  218. 'link_text': _(
  219. "Jinja2 template code for the link text. Reference the object as {example}. Links "
  220. "which render as empty text will not be displayed."
  221. ).format(example="<code>{{ object }}</code>"),
  222. 'link_url': _(
  223. "Jinja2 template code for the link URL. Reference the object as {example}."
  224. ).format(example="<code>{{ object }}</code>"),
  225. }
  226. class ExportTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm):
  227. object_types = ContentTypeMultipleChoiceField(
  228. label=_('Object types'),
  229. queryset=ObjectType.objects.with_feature('export_templates')
  230. )
  231. template_code = forms.CharField(
  232. label=_('Template code'),
  233. required=False,
  234. widget=forms.Textarea(attrs={'class': 'font-monospace'})
  235. )
  236. fieldsets = (
  237. FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
  238. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  239. FieldSet(
  240. 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
  241. ),
  242. )
  243. class Meta:
  244. model = ExportTemplate
  245. fields = '__all__'
  246. def __init__(self, *args, **kwargs):
  247. super().__init__(*args, **kwargs)
  248. # Disable data field when a DataFile has been set
  249. if self.instance.data_file:
  250. self.fields['template_code'].widget.attrs['readonly'] = True
  251. self.fields['template_code'].help_text = _(
  252. 'Template content is populated from the remote source selected below.'
  253. )
  254. def clean(self):
  255. super().clean()
  256. if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
  257. raise forms.ValidationError(_("Must specify either local content or a data file"))
  258. return self.cleaned_data
  259. class SavedFilterForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  260. slug = SlugField()
  261. object_types = ContentTypeMultipleChoiceField(
  262. label=_('Object types'),
  263. queryset=ObjectType.objects.all()
  264. )
  265. parameters = JSONField()
  266. fieldsets = (
  267. FieldSet('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', name=_('Saved Filter')),
  268. FieldSet('parameters', name=_('Parameters')),
  269. )
  270. class Meta:
  271. model = SavedFilter
  272. exclude = ('user',)
  273. def __init__(self, *args, initial=None, **kwargs):
  274. # Convert any parameters delivered via initial data to JSON data
  275. if initial and 'parameters' in initial:
  276. if type(initial['parameters']) is str:
  277. initial['parameters'] = json.loads(initial['parameters'])
  278. super().__init__(*args, initial=initial, **kwargs)
  279. class TableConfigForm(forms.ModelForm):
  280. object_type = ContentTypeChoiceField(
  281. label=_('Object type'),
  282. queryset=ObjectType.objects.all()
  283. )
  284. ordering = SimpleArrayField(
  285. base_field=forms.CharField(),
  286. required=False,
  287. label=_('Ordering'),
  288. help_text=_(
  289. "Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order."
  290. )
  291. )
  292. available_columns = SimpleArrayField(
  293. base_field=forms.CharField(),
  294. required=False,
  295. widget=forms.SelectMultiple(
  296. attrs={'size': 10, 'class': 'form-select'}
  297. ),
  298. label=_('Available Columns')
  299. )
  300. columns = SimpleArrayField(
  301. base_field=forms.CharField(),
  302. widget=forms.SelectMultiple(
  303. attrs={'size': 10, 'class': 'form-select select-all'}
  304. ),
  305. label=_('Selected Columns')
  306. )
  307. class Meta:
  308. model = TableConfig
  309. exclude = ('user',)
  310. def __init__(self, data=None, *args, **kwargs):
  311. super().__init__(data, *args, **kwargs)
  312. object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
  313. model = object_type.model_class()
  314. table_name = get_field_value(self, 'table')
  315. table_class = get_table_for_model(model, table_name)
  316. table = table_class([])
  317. if columns := self._get_columns():
  318. table._set_columns(columns)
  319. # Initialize columns field based on table attributes
  320. self.fields['available_columns'].widget.choices = table.available_columns
  321. self.fields['columns'].widget.choices = table.selected_columns
  322. def _get_columns(self):
  323. if self.is_bound and (columns := self.data.getlist('columns')):
  324. return columns
  325. if 'columns' in self.initial:
  326. columns = self.get_initial_for_field(self.fields['columns'], 'columns')
  327. return columns.split(',') if type(columns) is str else columns
  328. if self.instance is not None:
  329. return self.instance.columns
  330. class BookmarkForm(forms.ModelForm):
  331. object_type = ContentTypeChoiceField(
  332. label=_('Object type'),
  333. queryset=ObjectType.objects.with_feature('bookmarks')
  334. )
  335. class Meta:
  336. model = Bookmark
  337. fields = ('object_type', 'object_id')
  338. class NotificationGroupForm(ChangelogMessageMixin, forms.ModelForm):
  339. groups = DynamicModelMultipleChoiceField(
  340. label=_('Groups'),
  341. required=False,
  342. queryset=Group.objects.all()
  343. )
  344. users = DynamicModelMultipleChoiceField(
  345. label=_('Users'),
  346. required=False,
  347. queryset=User.objects.all()
  348. )
  349. class Meta:
  350. model = NotificationGroup
  351. fields = ('name', 'description', 'groups', 'users')
  352. def clean(self):
  353. super().clean()
  354. # At least one User or Group must be assigned
  355. if not self.cleaned_data['groups'] and not self.cleaned_data['users']:
  356. raise forms.ValidationError(_("A notification group specify at least one user or group."))
  357. return self.cleaned_data
  358. class SubscriptionForm(forms.ModelForm):
  359. object_type = ContentTypeChoiceField(
  360. label=_('Object type'),
  361. queryset=ObjectType.objects.with_feature('notifications')
  362. )
  363. class Meta:
  364. model = Subscription
  365. fields = ('object_type', 'object_id')
  366. class WebhookForm(OwnerMixin, NetBoxModelForm):
  367. fieldsets = (
  368. FieldSet('name', 'description', 'tags', name=_('Webhook')),
  369. FieldSet(
  370. 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
  371. name=_('HTTP Request')
  372. ),
  373. FieldSet('ssl_verification', 'ca_file_path', name=_('SSL')),
  374. )
  375. class Meta:
  376. model = Webhook
  377. fields = '__all__'
  378. widgets = {
  379. 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
  380. 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
  381. }
  382. class EventRuleForm(OwnerMixin, NetBoxModelForm):
  383. object_types = ContentTypeMultipleChoiceField(
  384. label=_('Object types'),
  385. queryset=ObjectType.objects.with_feature('event_rules'),
  386. )
  387. event_types = forms.MultipleChoiceField(
  388. choices=get_event_type_choices(),
  389. label=_('Event types')
  390. )
  391. action_choice = forms.ChoiceField(
  392. label=_('Action choice'),
  393. choices=[]
  394. )
  395. conditions = JSONField(
  396. required=False,
  397. help_text=_('Enter conditions in <a href="https://json.org/">JSON</a> format.')
  398. )
  399. action_data = JSONField(
  400. required=False,
  401. help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
  402. )
  403. comments = CommentField()
  404. fieldsets = (
  405. FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
  406. FieldSet('event_types', 'conditions', name=_('Triggers')),
  407. FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
  408. )
  409. class Meta:
  410. model = EventRule
  411. fields = (
  412. 'object_types', 'name', 'description', 'enabled', 'event_types', 'conditions', 'action_type',
  413. 'action_object_type', 'action_object_id', 'action_data', 'owner', 'comments', 'tags'
  414. )
  415. widgets = {
  416. 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
  417. 'action_type': HTMXSelect(),
  418. 'action_object_type': forms.HiddenInput,
  419. 'action_object_id': forms.HiddenInput,
  420. }
  421. def init_script_choice(self):
  422. initial = None
  423. if self.instance.action_type == EventRuleActionChoices.SCRIPT:
  424. script_id = get_field_value(self, 'action_object_id')
  425. initial = Script.objects.get(pk=script_id) if script_id else None
  426. self.fields['action_choice'] = DynamicModelChoiceField(
  427. label=_('Script'),
  428. queryset=Script.objects.all(),
  429. required=True,
  430. initial=initial
  431. )
  432. def init_webhook_choice(self):
  433. initial = None
  434. if self.instance.action_type == EventRuleActionChoices.WEBHOOK:
  435. webhook_id = get_field_value(self, 'action_object_id')
  436. initial = Webhook.objects.get(pk=webhook_id) if webhook_id else None
  437. self.fields['action_choice'] = DynamicModelChoiceField(
  438. label=_('Webhook'),
  439. queryset=Webhook.objects.all(),
  440. required=True,
  441. initial=initial
  442. )
  443. def init_notificationgroup_choice(self):
  444. initial = None
  445. if self.instance.action_type == EventRuleActionChoices.NOTIFICATION:
  446. notificationgroup_id = get_field_value(self, 'action_object_id')
  447. initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None
  448. self.fields['action_choice'] = DynamicModelChoiceField(
  449. label=_('Notification group'),
  450. queryset=NotificationGroup.objects.all(),
  451. required=True,
  452. initial=initial
  453. )
  454. def __init__(self, *args, **kwargs):
  455. super().__init__(*args, **kwargs)
  456. self.fields['action_object_type'].required = False
  457. self.fields['action_object_id'].required = False
  458. # Determine the action type
  459. action_type = get_field_value(self, 'action_type')
  460. if action_type == EventRuleActionChoices.WEBHOOK:
  461. self.init_webhook_choice()
  462. elif action_type == EventRuleActionChoices.SCRIPT:
  463. self.init_script_choice()
  464. elif action_type == EventRuleActionChoices.NOTIFICATION:
  465. self.init_notificationgroup_choice()
  466. def clean(self):
  467. super().clean()
  468. action_choice = self.cleaned_data.get('action_choice')
  469. # Webhook
  470. if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK:
  471. self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
  472. self.cleaned_data['action_object_id'] = action_choice.id
  473. # Script
  474. elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
  475. self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(
  476. Script,
  477. for_concrete_model=False
  478. )
  479. self.cleaned_data['action_object_id'] = action_choice.id
  480. # Notification
  481. elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION:
  482. self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
  483. self.cleaned_data['action_object_id'] = action_choice.id
  484. return self.cleaned_data
  485. class TagForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  486. slug = SlugField()
  487. object_types = ContentTypeMultipleChoiceField(
  488. label=_('Object types'),
  489. queryset=ObjectType.objects.with_feature('tags'),
  490. required=False
  491. )
  492. weight = forms.IntegerField(
  493. label=_('Weight'),
  494. required=False
  495. )
  496. fieldsets = (
  497. FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
  498. )
  499. class Meta:
  500. model = Tag
  501. fields = [
  502. 'name', 'slug', 'color', 'weight', 'description', 'object_types', 'owner',
  503. ]
  504. class ConfigContextProfileForm(SyncedDataMixin, PrimaryModelForm):
  505. schema = JSONField(
  506. label=_('Schema'),
  507. required=False,
  508. help_text=_("Enter a valid JSON schema to define supported attributes.")
  509. )
  510. tags = DynamicModelMultipleChoiceField(
  511. label=_('Tags'),
  512. queryset=Tag.objects.all(),
  513. required=False
  514. )
  515. fieldsets = (
  516. FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')),
  517. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  518. )
  519. class Meta:
  520. model = ConfigContextProfile
  521. fields = (
  522. 'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'owner', 'comments',
  523. 'tags',
  524. )
  525. class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm):
  526. profile = DynamicModelChoiceField(
  527. label=_('Profile'),
  528. queryset=ConfigContextProfile.objects.all(),
  529. required=False
  530. )
  531. regions = DynamicModelMultipleChoiceField(
  532. label=_('Regions'),
  533. queryset=Region.objects.all(),
  534. required=False
  535. )
  536. site_groups = DynamicModelMultipleChoiceField(
  537. label=_('Site groups'),
  538. queryset=SiteGroup.objects.all(),
  539. required=False
  540. )
  541. sites = DynamicModelMultipleChoiceField(
  542. label=_('Sites'),
  543. queryset=Site.objects.all(),
  544. required=False
  545. )
  546. locations = DynamicModelMultipleChoiceField(
  547. label=_('Locations'),
  548. queryset=Location.objects.all(),
  549. required=False
  550. )
  551. device_types = DynamicModelMultipleChoiceField(
  552. label=_('Device types'),
  553. queryset=DeviceType.objects.all(),
  554. required=False
  555. )
  556. roles = DynamicModelMultipleChoiceField(
  557. label=_('Roles'),
  558. queryset=DeviceRole.objects.all(),
  559. required=False
  560. )
  561. platforms = DynamicModelMultipleChoiceField(
  562. label=_('Platforms'),
  563. queryset=Platform.objects.all(),
  564. required=False
  565. )
  566. cluster_types = DynamicModelMultipleChoiceField(
  567. label=_('Cluster types'),
  568. queryset=ClusterType.objects.all(),
  569. required=False
  570. )
  571. cluster_groups = DynamicModelMultipleChoiceField(
  572. label=_('Cluster groups'),
  573. queryset=ClusterGroup.objects.all(),
  574. required=False
  575. )
  576. clusters = DynamicModelMultipleChoiceField(
  577. label=_('Clusters'),
  578. queryset=Cluster.objects.all(),
  579. required=False
  580. )
  581. tenant_groups = DynamicModelMultipleChoiceField(
  582. label=_('Tenant groups'),
  583. queryset=TenantGroup.objects.all(),
  584. required=False
  585. )
  586. tenants = DynamicModelMultipleChoiceField(
  587. label=_('Tenants'),
  588. queryset=Tenant.objects.all(),
  589. required=False
  590. )
  591. tags = DynamicModelMultipleChoiceField(
  592. label=_('Tags'),
  593. queryset=Tag.objects.all(),
  594. required=False
  595. )
  596. data = JSONField(
  597. label=_('Data'),
  598. required=False
  599. )
  600. fieldsets = (
  601. FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')),
  602. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  603. FieldSet(
  604. 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
  605. 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
  606. name=_('Assignment')
  607. ),
  608. )
  609. class Meta:
  610. model = ConfigContext
  611. fields = (
  612. 'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
  613. 'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
  614. 'tenant_groups', 'tenants', 'owner', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
  615. )
  616. def __init__(self, *args, initial=None, **kwargs):
  617. # Convert data delivered via initial data to JSON data
  618. if initial and 'data' in initial:
  619. if type(initial['data']) is str:
  620. initial['data'] = json.loads(initial['data'])
  621. super().__init__(*args, initial=initial, **kwargs)
  622. # Disable data field when a DataFile has been set
  623. if self.instance.data_file:
  624. self.fields['data'].widget.attrs['readonly'] = True
  625. self.fields['data'].help_text = _('Data is populated from the remote source selected below.')
  626. def clean(self):
  627. super().clean()
  628. if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'):
  629. raise forms.ValidationError(_("Must specify either local data or a data file"))
  630. return self.cleaned_data
  631. class ConfigTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm):
  632. tags = DynamicModelMultipleChoiceField(
  633. label=_('Tags'),
  634. queryset=Tag.objects.all(),
  635. required=False
  636. )
  637. template_code = forms.CharField(
  638. label=_('Template code'),
  639. required=False,
  640. widget=forms.Textarea(attrs={'class': 'font-monospace'})
  641. )
  642. fieldsets = (
  643. FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')),
  644. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  645. FieldSet(
  646. 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
  647. ),
  648. )
  649. class Meta:
  650. model = ConfigTemplate
  651. fields = '__all__'
  652. widgets = {
  653. 'environment_params': forms.Textarea(attrs={'rows': 5})
  654. }
  655. def __init__(self, *args, **kwargs):
  656. super().__init__(*args, **kwargs)
  657. # Disable content field when a DataFile has been set
  658. if self.instance.data_file:
  659. self.fields['template_code'].widget.attrs['readonly'] = True
  660. self.fields['template_code'].help_text = _(
  661. 'Template content is populated from the remote source selected below.'
  662. )
  663. def clean(self):
  664. super().clean()
  665. if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
  666. raise forms.ValidationError(_("Must specify either local content or a data file"))
  667. return self.cleaned_data
  668. class ImageAttachmentForm(forms.ModelForm):
  669. fieldsets = (
  670. FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'),
  671. )
  672. class Meta:
  673. model = ImageAttachment
  674. fields = [
  675. 'image', 'name', 'description',
  676. ]
  677. # Explicitly set 'image/avif' to support AVIF selection in Firefox
  678. widgets = {
  679. 'image': forms.ClearableFileInput(
  680. attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))}
  681. ),
  682. }
  683. class JournalEntryForm(NetBoxModelForm):
  684. kind = forms.ChoiceField(
  685. label=_('Kind'),
  686. choices=JournalEntryKindChoices
  687. )
  688. comments = CommentField(required=True)
  689. class Meta:
  690. model = JournalEntry
  691. fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'tags', 'comments']
  692. widgets = {
  693. 'assigned_object_type': forms.HiddenInput,
  694. 'assigned_object_id': forms.HiddenInput,
  695. }