model_forms.py 33 KB

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