model_forms.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  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. fieldsets = (
  164. FieldSet(
  165. 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
  166. name=_('Custom Field Choice Set')
  167. ),
  168. )
  169. class Meta:
  170. model = CustomFieldChoiceSet
  171. fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
  172. def __init__(self, *args, initial=None, **kwargs):
  173. super().__init__(*args, initial=initial, **kwargs)
  174. # TODO: The check for str / list below is to handle difference in extra_choices field definition
  175. # In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
  176. # if standardize these, we can simplify this code
  177. # Convert extra_choices Array Field from model to CharField for form
  178. if extra_choices := self.initial.get('extra_choices', None):
  179. if isinstance(extra_choices, str):
  180. extra_choices = [extra_choices]
  181. choices = []
  182. for choice in extra_choices:
  183. # Setup choices in Add Another use case
  184. if isinstance(choice, str):
  185. choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
  186. choices.append(choice_str)
  187. # Setup choices in Edit use case
  188. elif isinstance(choice, list):
  189. value = choice[0].replace(':', '\\:')
  190. label = choice[1].replace(':', '\\:')
  191. choices.append(f'{value}:{label}')
  192. self.initial['extra_choices'] = '\n'.join(choices)
  193. def clean_extra_choices(self):
  194. data = []
  195. for line in self.cleaned_data['extra_choices'].splitlines():
  196. try:
  197. value, label = re.split(r'(?<!\\):', line, maxsplit=1)
  198. value = value.replace('\\:', ':')
  199. label = label.replace('\\:', ':')
  200. except ValueError:
  201. value, label = line, line
  202. data.append((value.strip(), label.strip()))
  203. return data
  204. class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  205. object_types = ContentTypeMultipleChoiceField(
  206. label=_('Object types'),
  207. queryset=ObjectType.objects.with_feature('custom_links')
  208. )
  209. fieldsets = (
  210. FieldSet(
  211. 'name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window',
  212. name=_('Custom Link')
  213. ),
  214. FieldSet('link_text', 'link_url', name=_('Templates')),
  215. )
  216. class Meta:
  217. model = CustomLink
  218. fields = '__all__'
  219. widgets = {
  220. 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
  221. 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
  222. }
  223. help_texts = {
  224. 'link_text': _(
  225. "Jinja2 template code for the link text. Reference the object as {example}. Links "
  226. "which render as empty text will not be displayed."
  227. ).format(example="<code>{{ object }}</code>"),
  228. 'link_url': _(
  229. "Jinja2 template code for the link URL. Reference the object as {example}."
  230. ).format(example="<code>{{ object }}</code>"),
  231. }
  232. class ExportTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm):
  233. object_types = ContentTypeMultipleChoiceField(
  234. label=_('Object types'),
  235. queryset=ObjectType.objects.with_feature('export_templates')
  236. )
  237. template_code = forms.CharField(
  238. label=_('Template code'),
  239. required=False,
  240. widget=forms.Textarea(attrs={'class': 'font-monospace'})
  241. )
  242. fieldsets = (
  243. FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
  244. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  245. FieldSet(
  246. 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
  247. ),
  248. )
  249. class Meta:
  250. model = ExportTemplate
  251. fields = '__all__'
  252. def __init__(self, *args, **kwargs):
  253. super().__init__(*args, **kwargs)
  254. # Disable data field when a DataFile has been set
  255. if self.instance.data_file:
  256. self.fields['template_code'].widget.attrs['readonly'] = True
  257. self.fields['template_code'].help_text = _(
  258. 'Template content is populated from the remote source selected below.'
  259. )
  260. def clean(self):
  261. super().clean()
  262. if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
  263. raise forms.ValidationError(_("Must specify either local content or a data file"))
  264. return self.cleaned_data
  265. class SavedFilterForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  266. slug = SlugField()
  267. object_types = ContentTypeMultipleChoiceField(
  268. label=_('Object types'),
  269. queryset=ObjectType.objects.all()
  270. )
  271. parameters = JSONField()
  272. fieldsets = (
  273. FieldSet('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', name=_('Saved Filter')),
  274. FieldSet('parameters', name=_('Parameters')),
  275. )
  276. class Meta:
  277. model = SavedFilter
  278. exclude = ('user',)
  279. def __init__(self, *args, initial=None, **kwargs):
  280. # Convert any parameters delivered via initial data to JSON data
  281. if initial and 'parameters' in initial:
  282. if type(initial['parameters']) is str:
  283. initial['parameters'] = json.loads(initial['parameters'])
  284. super().__init__(*args, initial=initial, **kwargs)
  285. class TableConfigForm(forms.ModelForm):
  286. object_type = ContentTypeChoiceField(
  287. label=_('Object type'),
  288. queryset=ObjectType.objects.all()
  289. )
  290. ordering = SimpleArrayField(
  291. base_field=forms.CharField(),
  292. required=False,
  293. label=_('Ordering'),
  294. help_text=_(
  295. "Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order."
  296. )
  297. )
  298. available_columns = SimpleArrayField(
  299. base_field=forms.CharField(),
  300. required=False,
  301. widget=forms.SelectMultiple(
  302. attrs={'size': 10, 'class': 'form-select'}
  303. ),
  304. label=_('Available Columns')
  305. )
  306. columns = SimpleArrayField(
  307. base_field=forms.CharField(),
  308. widget=forms.SelectMultiple(
  309. attrs={'size': 10, 'class': 'form-select select-all'}
  310. ),
  311. label=_('Selected Columns')
  312. )
  313. class Meta:
  314. model = TableConfig
  315. exclude = ('user',)
  316. def __init__(self, data=None, *args, **kwargs):
  317. super().__init__(data, *args, **kwargs)
  318. object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
  319. model = object_type.model_class()
  320. table_name = get_field_value(self, 'table')
  321. table_class = get_table_for_model(model, table_name)
  322. table = table_class([])
  323. if columns := self._get_columns():
  324. table._set_columns(columns)
  325. # Initialize columns field based on table attributes
  326. self.fields['available_columns'].widget.choices = table.available_columns
  327. self.fields['columns'].widget.choices = table.selected_columns
  328. def _get_columns(self):
  329. if self.is_bound and (columns := self.data.getlist('columns')):
  330. return columns
  331. if 'columns' in self.initial:
  332. columns = self.get_initial_for_field(self.fields['columns'], 'columns')
  333. return columns.split(',') if type(columns) is str else columns
  334. if self.instance is not None:
  335. return self.instance.columns
  336. class BookmarkForm(forms.ModelForm):
  337. object_type = ContentTypeChoiceField(
  338. label=_('Object type'),
  339. queryset=ObjectType.objects.with_feature('bookmarks')
  340. )
  341. class Meta:
  342. model = Bookmark
  343. fields = ('object_type', 'object_id')
  344. class NotificationGroupForm(ChangelogMessageMixin, forms.ModelForm):
  345. groups = DynamicModelMultipleChoiceField(
  346. label=_('Groups'),
  347. required=False,
  348. queryset=Group.objects.all()
  349. )
  350. users = DynamicModelMultipleChoiceField(
  351. label=_('Users'),
  352. required=False,
  353. queryset=User.objects.all()
  354. )
  355. class Meta:
  356. model = NotificationGroup
  357. fields = ('name', 'description', 'groups', 'users')
  358. def clean(self):
  359. super().clean()
  360. # At least one User or Group must be assigned
  361. if not self.cleaned_data['groups'] and not self.cleaned_data['users']:
  362. raise forms.ValidationError(_("A notification group specify at least one user or group."))
  363. return self.cleaned_data
  364. class SubscriptionForm(forms.ModelForm):
  365. object_type = ContentTypeChoiceField(
  366. label=_('Object type'),
  367. queryset=ObjectType.objects.with_feature('notifications')
  368. )
  369. class Meta:
  370. model = Subscription
  371. fields = ('object_type', 'object_id')
  372. class WebhookForm(OwnerMixin, NetBoxModelForm):
  373. fieldsets = (
  374. FieldSet('name', 'description', 'tags', name=_('Webhook')),
  375. FieldSet(
  376. 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
  377. name=_('HTTP Request')
  378. ),
  379. FieldSet('ssl_verification', 'ca_file_path', name=_('SSL')),
  380. )
  381. class Meta:
  382. model = Webhook
  383. fields = '__all__'
  384. widgets = {
  385. 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
  386. 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
  387. }
  388. class EventRuleForm(OwnerMixin, NetBoxModelForm):
  389. object_types = ContentTypeMultipleChoiceField(
  390. label=_('Object types'),
  391. queryset=ObjectType.objects.with_feature('event_rules'),
  392. )
  393. event_types = forms.MultipleChoiceField(
  394. choices=get_event_type_choices(),
  395. label=_('Event types')
  396. )
  397. action_choice = forms.ChoiceField(
  398. label=_('Action choice'),
  399. choices=[]
  400. )
  401. conditions = JSONField(
  402. required=False,
  403. help_text=_('Enter conditions in <a href="https://json.org/">JSON</a> format.')
  404. )
  405. action_data = JSONField(
  406. required=False,
  407. help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
  408. )
  409. comments = CommentField()
  410. fieldsets = (
  411. FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
  412. FieldSet('event_types', 'conditions', name=_('Triggers')),
  413. FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
  414. )
  415. class Meta:
  416. model = EventRule
  417. fields = (
  418. 'object_types', 'name', 'description', 'enabled', 'event_types', 'conditions', 'action_type',
  419. 'action_object_type', 'action_object_id', 'action_data', 'owner', 'comments', 'tags'
  420. )
  421. widgets = {
  422. 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
  423. 'action_type': HTMXSelect(),
  424. 'action_object_type': forms.HiddenInput,
  425. 'action_object_id': forms.HiddenInput,
  426. }
  427. def init_script_choice(self):
  428. initial = None
  429. if self.instance.action_type == EventRuleActionChoices.SCRIPT:
  430. script_id = get_field_value(self, 'action_object_id')
  431. initial = Script.objects.get(pk=script_id) if script_id else None
  432. self.fields['action_choice'] = DynamicModelChoiceField(
  433. label=_('Script'),
  434. queryset=Script.objects.all(),
  435. required=True,
  436. initial=initial
  437. )
  438. def init_webhook_choice(self):
  439. initial = None
  440. if self.instance.action_type == EventRuleActionChoices.WEBHOOK:
  441. webhook_id = get_field_value(self, 'action_object_id')
  442. initial = Webhook.objects.get(pk=webhook_id) if webhook_id else None
  443. self.fields['action_choice'] = DynamicModelChoiceField(
  444. label=_('Webhook'),
  445. queryset=Webhook.objects.all(),
  446. required=True,
  447. initial=initial
  448. )
  449. def init_notificationgroup_choice(self):
  450. initial = None
  451. if self.instance.action_type == EventRuleActionChoices.NOTIFICATION:
  452. notificationgroup_id = get_field_value(self, 'action_object_id')
  453. initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None
  454. self.fields['action_choice'] = DynamicModelChoiceField(
  455. label=_('Notification group'),
  456. queryset=NotificationGroup.objects.all(),
  457. required=True,
  458. initial=initial
  459. )
  460. def __init__(self, *args, **kwargs):
  461. super().__init__(*args, **kwargs)
  462. self.fields['action_object_type'].required = False
  463. self.fields['action_object_id'].required = False
  464. # Determine the action type
  465. action_type = get_field_value(self, 'action_type')
  466. if action_type == EventRuleActionChoices.WEBHOOK:
  467. self.init_webhook_choice()
  468. elif action_type == EventRuleActionChoices.SCRIPT:
  469. self.init_script_choice()
  470. elif action_type == EventRuleActionChoices.NOTIFICATION:
  471. self.init_notificationgroup_choice()
  472. def clean(self):
  473. super().clean()
  474. action_choice = self.cleaned_data.get('action_choice')
  475. # Webhook
  476. if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK:
  477. self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
  478. self.cleaned_data['action_object_id'] = action_choice.id
  479. # Script
  480. elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
  481. self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(
  482. Script,
  483. for_concrete_model=False
  484. )
  485. self.cleaned_data['action_object_id'] = action_choice.id
  486. # Notification
  487. elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION:
  488. self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
  489. self.cleaned_data['action_object_id'] = action_choice.id
  490. return self.cleaned_data
  491. class TagForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
  492. slug = SlugField()
  493. object_types = ContentTypeMultipleChoiceField(
  494. label=_('Object types'),
  495. queryset=ObjectType.objects.with_feature('tags'),
  496. required=False
  497. )
  498. fieldsets = (
  499. FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
  500. )
  501. class Meta:
  502. model = Tag
  503. fields = [
  504. 'name', 'slug', 'color', 'weight', 'description', 'object_types', 'owner',
  505. ]
  506. class ConfigContextProfileForm(SyncedDataMixin, PrimaryModelForm):
  507. schema = JSONField(
  508. label=_('Schema'),
  509. required=False,
  510. help_text=_("Enter a valid JSON schema to define supported attributes.")
  511. )
  512. tags = DynamicModelMultipleChoiceField(
  513. label=_('Tags'),
  514. queryset=Tag.objects.all(),
  515. required=False
  516. )
  517. fieldsets = (
  518. FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')),
  519. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  520. )
  521. class Meta:
  522. model = ConfigContextProfile
  523. fields = (
  524. 'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'owner', 'comments',
  525. 'tags',
  526. )
  527. class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm):
  528. profile = DynamicModelChoiceField(
  529. label=_('Profile'),
  530. queryset=ConfigContextProfile.objects.all(),
  531. required=False
  532. )
  533. regions = DynamicModelMultipleChoiceField(
  534. label=_('Regions'),
  535. queryset=Region.objects.all(),
  536. required=False
  537. )
  538. site_groups = DynamicModelMultipleChoiceField(
  539. label=_('Site groups'),
  540. queryset=SiteGroup.objects.all(),
  541. required=False
  542. )
  543. sites = DynamicModelMultipleChoiceField(
  544. label=_('Sites'),
  545. queryset=Site.objects.all(),
  546. required=False
  547. )
  548. locations = DynamicModelMultipleChoiceField(
  549. label=_('Locations'),
  550. queryset=Location.objects.all(),
  551. required=False
  552. )
  553. device_types = DynamicModelMultipleChoiceField(
  554. label=_('Device types'),
  555. queryset=DeviceType.objects.all(),
  556. required=False
  557. )
  558. roles = DynamicModelMultipleChoiceField(
  559. label=_('Roles'),
  560. queryset=DeviceRole.objects.all(),
  561. required=False
  562. )
  563. platforms = DynamicModelMultipleChoiceField(
  564. label=_('Platforms'),
  565. queryset=Platform.objects.all(),
  566. required=False
  567. )
  568. cluster_types = DynamicModelMultipleChoiceField(
  569. label=_('Cluster types'),
  570. queryset=ClusterType.objects.all(),
  571. required=False
  572. )
  573. cluster_groups = DynamicModelMultipleChoiceField(
  574. label=_('Cluster groups'),
  575. queryset=ClusterGroup.objects.all(),
  576. required=False
  577. )
  578. clusters = DynamicModelMultipleChoiceField(
  579. label=_('Clusters'),
  580. queryset=Cluster.objects.all(),
  581. required=False
  582. )
  583. tenant_groups = DynamicModelMultipleChoiceField(
  584. label=_('Tenant groups'),
  585. queryset=TenantGroup.objects.all(),
  586. required=False
  587. )
  588. tenants = DynamicModelMultipleChoiceField(
  589. label=_('Tenants'),
  590. queryset=Tenant.objects.all(),
  591. required=False
  592. )
  593. tags = DynamicModelMultipleChoiceField(
  594. label=_('Tags'),
  595. queryset=Tag.objects.all(),
  596. required=False
  597. )
  598. data = JSONField(
  599. label=_('Data'),
  600. required=False
  601. )
  602. fieldsets = (
  603. FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')),
  604. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  605. FieldSet(
  606. 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
  607. 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
  608. name=_('Assignment')
  609. ),
  610. )
  611. class Meta:
  612. model = ConfigContext
  613. fields = (
  614. 'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
  615. 'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
  616. 'tenant_groups', 'tenants', 'owner', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
  617. )
  618. def __init__(self, *args, initial=None, **kwargs):
  619. # Convert data delivered via initial data to JSON data
  620. if initial and 'data' in initial:
  621. if type(initial['data']) is str:
  622. initial['data'] = json.loads(initial['data'])
  623. super().__init__(*args, initial=initial, **kwargs)
  624. # Disable data field when a DataFile has been set
  625. if self.instance.data_file:
  626. self.fields['data'].widget.attrs['readonly'] = True
  627. self.fields['data'].help_text = _('Data is populated from the remote source selected below.')
  628. def clean(self):
  629. super().clean()
  630. if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'):
  631. raise forms.ValidationError(_("Must specify either local data or a data file"))
  632. return self.cleaned_data
  633. class ConfigTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, forms.ModelForm):
  634. tags = DynamicModelMultipleChoiceField(
  635. label=_('Tags'),
  636. queryset=Tag.objects.all(),
  637. required=False
  638. )
  639. template_code = forms.CharField(
  640. label=_('Template code'),
  641. required=False,
  642. widget=forms.Textarea(attrs={'class': 'font-monospace'})
  643. )
  644. fieldsets = (
  645. FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')),
  646. FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
  647. FieldSet(
  648. 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
  649. ),
  650. )
  651. class Meta:
  652. model = ConfigTemplate
  653. fields = '__all__'
  654. widgets = {
  655. 'environment_params': forms.Textarea(attrs={'rows': 5})
  656. }
  657. def __init__(self, *args, **kwargs):
  658. super().__init__(*args, **kwargs)
  659. # Disable content field when a DataFile has been set
  660. if self.instance.data_file:
  661. self.fields['template_code'].widget.attrs['readonly'] = True
  662. self.fields['template_code'].help_text = _(
  663. 'Template content is populated from the remote source selected below.'
  664. )
  665. def clean(self):
  666. super().clean()
  667. if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
  668. raise forms.ValidationError(_("Must specify either local content or a data file"))
  669. return self.cleaned_data
  670. class ImageAttachmentForm(forms.ModelForm):
  671. fieldsets = (
  672. FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'),
  673. )
  674. class Meta:
  675. model = ImageAttachment
  676. fields = [
  677. 'image', 'name', 'description',
  678. ]
  679. # Explicitly set 'image/avif' to support AVIF selection in Firefox
  680. widgets = {
  681. 'image': forms.ClearableFileInput(
  682. attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))}
  683. ),
  684. }
  685. class JournalEntryForm(NetBoxModelForm):
  686. kind = forms.ChoiceField(
  687. label=_('Kind'),
  688. choices=JournalEntryKindChoices
  689. )
  690. comments = CommentField(required=True)
  691. class Meta:
  692. model = JournalEntry
  693. fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'tags', 'comments']
  694. widgets = {
  695. 'assigned_object_type': forms.HiddenInput,
  696. 'assigned_object_id': forms.HiddenInput,
  697. }