model_forms.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import json
  2. import re
  3. from django import forms
  4. from django.contrib.contenttypes.models import ContentType
  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 ContentType
  9. from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
  10. from extras.choices import *
  11. from extras.models import *
  12. from netbox.forms import NetBoxModelForm
  13. from tenancy.models import Tenant, TenantGroup
  14. from utilities.forms import add_blank_choice, get_field_value
  15. from utilities.forms.fields import (
  16. CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
  17. DynamicModelMultipleChoiceField, JSONField, SlugField,
  18. )
  19. from utilities.forms.widgets import ChoicesWidget, HTMXSelect
  20. from virtualization.models import Cluster, ClusterGroup, ClusterType
  21. __all__ = (
  22. 'BookmarkForm',
  23. 'ConfigContextForm',
  24. 'ConfigTemplateForm',
  25. 'CustomFieldChoiceSetForm',
  26. 'CustomFieldForm',
  27. 'CustomLinkForm',
  28. 'EventRuleForm',
  29. 'ExportTemplateForm',
  30. 'ImageAttachmentForm',
  31. 'JournalEntryForm',
  32. 'SavedFilterForm',
  33. 'TagForm',
  34. 'WebhookForm',
  35. )
  36. class CustomFieldForm(forms.ModelForm):
  37. content_types = ContentTypeMultipleChoiceField(
  38. label=_('Content types'),
  39. queryset=ContentType.objects.with_feature('custom_fields')
  40. )
  41. object_type = ContentTypeChoiceField(
  42. label=_('Object type'),
  43. queryset=ContentType.objects.public(),
  44. required=False,
  45. help_text=_("Type of the related object (for object/multi-object fields only)")
  46. )
  47. choice_set = DynamicModelChoiceField(
  48. queryset=CustomFieldChoiceSet.objects.all(),
  49. required=False
  50. )
  51. fieldsets = (
  52. (_('Custom Field'), (
  53. 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
  54. )),
  55. (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')),
  56. (_('Values'), ('default', 'choice_set')),
  57. (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')),
  58. )
  59. class Meta:
  60. model = CustomField
  61. fields = '__all__'
  62. help_texts = {
  63. 'type': _(
  64. "The type of data stored in this field. For object/multi-object fields, select the related object "
  65. "type below."
  66. ),
  67. 'description': _("This will be displayed as help text for the form field. Markdown is supported.")
  68. }
  69. def __init__(self, *args, **kwargs):
  70. super().__init__(*args, **kwargs)
  71. # Disable changing the type of a CustomField as it almost universally causes errors if custom field data
  72. # is already present.
  73. if self.instance.pk:
  74. self.fields['type'].disabled = True
  75. class CustomFieldChoiceSetForm(forms.ModelForm):
  76. extra_choices = forms.CharField(
  77. widget=ChoicesWidget(),
  78. required=False,
  79. help_text=mark_safe(_(
  80. 'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
  81. 'colon. Example:'
  82. ) + ' <code>choice1:First Choice</code>')
  83. )
  84. class Meta:
  85. model = CustomFieldChoiceSet
  86. fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
  87. def __init__(self, *args, initial=None, **kwargs):
  88. super().__init__(*args, initial=initial, **kwargs)
  89. # Escape colons in extra_choices
  90. if 'extra_choices' in self.initial and self.initial['extra_choices']:
  91. choices = []
  92. for choice in self.initial['extra_choices']:
  93. choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
  94. choices.append(choice)
  95. self.initial['extra_choices'] = choices
  96. def clean_extra_choices(self):
  97. data = []
  98. for line in self.cleaned_data['extra_choices'].splitlines():
  99. try:
  100. value, label = re.split(r'(?<!\\):', line, maxsplit=1)
  101. value = value.replace('\\:', ':')
  102. label = label.replace('\\:', ':')
  103. except ValueError:
  104. value, label = line, line
  105. data.append((value, label))
  106. return data
  107. class CustomLinkForm(forms.ModelForm):
  108. content_types = ContentTypeMultipleChoiceField(
  109. label=_('Content types'),
  110. queryset=ContentType.objects.with_feature('custom_links')
  111. )
  112. fieldsets = (
  113. (_('Custom Link'), ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
  114. (_('Templates'), ('link_text', 'link_url')),
  115. )
  116. class Meta:
  117. model = CustomLink
  118. fields = '__all__'
  119. widgets = {
  120. 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
  121. 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
  122. }
  123. help_texts = {
  124. 'link_text': _(
  125. "Jinja2 template code for the link text. Reference the object as {example}. Links "
  126. "which render as empty text will not be displayed."
  127. ).format(example="<code>{{ object }}</code>"),
  128. 'link_url': _(
  129. "Jinja2 template code for the link URL. Reference the object as {example}."
  130. ).format(example="<code>{{ object }}</code>"),
  131. }
  132. class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
  133. content_types = ContentTypeMultipleChoiceField(
  134. label=_('Content types'),
  135. queryset=ContentType.objects.with_feature('export_templates')
  136. )
  137. template_code = forms.CharField(
  138. label=_('Template code'),
  139. required=False,
  140. widget=forms.Textarea(attrs={'class': 'font-monospace'})
  141. )
  142. fieldsets = (
  143. (_('Export Template'), ('name', 'content_types', 'description', 'template_code')),
  144. (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')),
  145. (_('Rendering'), ('mime_type', 'file_extension', 'as_attachment')),
  146. )
  147. class Meta:
  148. model = ExportTemplate
  149. fields = '__all__'
  150. def __init__(self, *args, **kwargs):
  151. super().__init__(*args, **kwargs)
  152. # Disable data field when a DataFile has been set
  153. if self.instance.data_file:
  154. self.fields['template_code'].widget.attrs['readonly'] = True
  155. self.fields['template_code'].help_text = _(
  156. 'Template content is populated from the remote source selected below.'
  157. )
  158. def clean(self):
  159. super().clean()
  160. if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
  161. raise forms.ValidationError(_("Must specify either local content or a data file"))
  162. return self.cleaned_data
  163. class SavedFilterForm(forms.ModelForm):
  164. slug = SlugField()
  165. content_types = ContentTypeMultipleChoiceField(
  166. label=_('Content types'),
  167. queryset=ContentType.objects.all()
  168. )
  169. parameters = JSONField()
  170. fieldsets = (
  171. (_('Saved Filter'), ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')),
  172. (_('Parameters'), ('parameters',)),
  173. )
  174. class Meta:
  175. model = SavedFilter
  176. exclude = ('user',)
  177. def __init__(self, *args, initial=None, **kwargs):
  178. # Convert any parameters delivered via initial data to JSON data
  179. if initial and 'parameters' in initial:
  180. if type(initial['parameters']) is str:
  181. initial['parameters'] = json.loads(initial['parameters'])
  182. super().__init__(*args, initial=initial, **kwargs)
  183. class BookmarkForm(forms.ModelForm):
  184. object_type = ContentTypeChoiceField(
  185. label=_('Object type'),
  186. queryset=ContentType.objects.with_feature('bookmarks')
  187. )
  188. class Meta:
  189. model = Bookmark
  190. fields = ('object_type', 'object_id')
  191. class WebhookForm(NetBoxModelForm):
  192. fieldsets = (
  193. (_('Webhook'), ('name', 'description', 'tags',)),
  194. (_('HTTP Request'), (
  195. 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
  196. )),
  197. (_('SSL'), ('ssl_verification', 'ca_file_path')),
  198. )
  199. class Meta:
  200. model = Webhook
  201. fields = '__all__'
  202. widgets = {
  203. 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
  204. 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
  205. }
  206. class EventRuleForm(NetBoxModelForm):
  207. content_types = ContentTypeMultipleChoiceField(
  208. label=_('Content types'),
  209. queryset=ContentType.objects.with_feature('event_rules'),
  210. )
  211. action_choice = forms.ChoiceField(
  212. label=_('Action choice'),
  213. choices=[]
  214. )
  215. conditions = JSONField(
  216. required=False,
  217. help_text=_('Enter conditions in <a href="https://json.org/">JSON</a> format.')
  218. )
  219. action_data = JSONField(
  220. required=False,
  221. help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
  222. )
  223. fieldsets = (
  224. (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),
  225. (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
  226. (_('Conditions'), ('conditions',)),
  227. (_('Action'), (
  228. 'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data',
  229. )),
  230. )
  231. class Meta:
  232. model = EventRule
  233. fields = (
  234. 'content_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start',
  235. 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id',
  236. 'action_data', 'comments', 'tags'
  237. )
  238. labels = {
  239. 'type_create': _('Creations'),
  240. 'type_update': _('Updates'),
  241. 'type_delete': _('Deletions'),
  242. 'type_job_start': _('Job executions'),
  243. 'type_job_end': _('Job terminations'),
  244. }
  245. widgets = {
  246. 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
  247. 'action_type': HTMXSelect(),
  248. 'action_object_type': forms.HiddenInput,
  249. 'action_object_id': forms.HiddenInput,
  250. }
  251. def init_script_choice(self):
  252. choices = []
  253. for module in ScriptModule.objects.all():
  254. scripts = []
  255. for script_name in module.scripts.keys():
  256. name = f"{str(module.pk)}:{script_name}"
  257. scripts.append((name, script_name))
  258. if scripts:
  259. choices.append((str(module), scripts))
  260. self.fields['action_choice'].choices = choices
  261. if self.instance.action_type == EventRuleActionChoices.SCRIPT and self.instance.action_parameters:
  262. scriptmodule_id = self.instance.action_object_id
  263. script_name = self.instance.action_parameters.get('script_name')
  264. self.fields['action_choice'].initial = f'{scriptmodule_id}:{script_name}'
  265. def init_webhook_choice(self):
  266. initial = None
  267. if self.instance.action_type == EventRuleActionChoices.WEBHOOK:
  268. webhook_id = get_field_value(self, 'action_object_id')
  269. initial = Webhook.objects.get(pk=webhook_id) if webhook_id else None
  270. self.fields['action_choice'] = DynamicModelChoiceField(
  271. label=_('Webhook'),
  272. queryset=Webhook.objects.all(),
  273. required=True,
  274. initial=initial
  275. )
  276. def __init__(self, *args, **kwargs):
  277. super().__init__(*args, **kwargs)
  278. self.fields['action_object_type'].required = False
  279. self.fields['action_object_id'].required = False
  280. # Determine the action type
  281. action_type = get_field_value(self, 'action_type')
  282. if action_type == EventRuleActionChoices.WEBHOOK:
  283. self.init_webhook_choice()
  284. elif action_type == EventRuleActionChoices.SCRIPT:
  285. self.init_script_choice()
  286. def clean(self):
  287. super().clean()
  288. action_choice = self.cleaned_data.get('action_choice')
  289. # Webhook
  290. if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK:
  291. self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(action_choice)
  292. self.cleaned_data['action_object_id'] = action_choice.id
  293. # Script
  294. elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
  295. self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
  296. ScriptModule,
  297. for_concrete_model=False
  298. )
  299. module_id, script_name = action_choice.split(":", maxsplit=1)
  300. self.cleaned_data['action_object_id'] = module_id
  301. return self.cleaned_data
  302. def save(self, *args, **kwargs):
  303. # Set action_parameters on the instance
  304. if self.cleaned_data['action_type'] == EventRuleActionChoices.SCRIPT:
  305. module_id, script_name = self.cleaned_data.get('action_choice').split(":", maxsplit=1)
  306. self.instance.action_parameters = {
  307. 'script_name': script_name,
  308. }
  309. else:
  310. self.instance.action_parameters = None
  311. return super().save(*args, **kwargs)
  312. class TagForm(forms.ModelForm):
  313. slug = SlugField()
  314. object_types = ContentTypeMultipleChoiceField(
  315. label=_('Object types'),
  316. queryset=ContentType.objects.with_feature('tags'),
  317. required=False
  318. )
  319. fieldsets = (
  320. ('Tag', ('name', 'slug', 'color', 'description', 'object_types')),
  321. )
  322. class Meta:
  323. model = Tag
  324. fields = [
  325. 'name', 'slug', 'color', 'description', 'object_types',
  326. ]
  327. class ConfigContextForm(SyncedDataMixin, forms.ModelForm):
  328. regions = DynamicModelMultipleChoiceField(
  329. label=_('Regions'),
  330. queryset=Region.objects.all(),
  331. required=False
  332. )
  333. site_groups = DynamicModelMultipleChoiceField(
  334. label=_('Site groups'),
  335. queryset=SiteGroup.objects.all(),
  336. required=False
  337. )
  338. sites = DynamicModelMultipleChoiceField(
  339. label=_('Sites'),
  340. queryset=Site.objects.all(),
  341. required=False
  342. )
  343. locations = DynamicModelMultipleChoiceField(
  344. label=_('Locations'),
  345. queryset=Location.objects.all(),
  346. required=False
  347. )
  348. device_types = DynamicModelMultipleChoiceField(
  349. label=_('Device types'),
  350. queryset=DeviceType.objects.all(),
  351. required=False
  352. )
  353. roles = DynamicModelMultipleChoiceField(
  354. label=_('Roles'),
  355. queryset=DeviceRole.objects.all(),
  356. required=False
  357. )
  358. platforms = DynamicModelMultipleChoiceField(
  359. label=_('Platforms'),
  360. queryset=Platform.objects.all(),
  361. required=False
  362. )
  363. cluster_types = DynamicModelMultipleChoiceField(
  364. label=_('Cluster types'),
  365. queryset=ClusterType.objects.all(),
  366. required=False
  367. )
  368. cluster_groups = DynamicModelMultipleChoiceField(
  369. label=_('Cluster groups'),
  370. queryset=ClusterGroup.objects.all(),
  371. required=False
  372. )
  373. clusters = DynamicModelMultipleChoiceField(
  374. label=_('Clusters'),
  375. queryset=Cluster.objects.all(),
  376. required=False
  377. )
  378. tenant_groups = DynamicModelMultipleChoiceField(
  379. label=_('Tenant groups'),
  380. queryset=TenantGroup.objects.all(),
  381. required=False
  382. )
  383. tenants = DynamicModelMultipleChoiceField(
  384. label=_('Tenants'),
  385. queryset=Tenant.objects.all(),
  386. required=False
  387. )
  388. tags = DynamicModelMultipleChoiceField(
  389. label=_('Tags'),
  390. queryset=Tag.objects.all(),
  391. required=False
  392. )
  393. data = JSONField(
  394. label=_('Data'),
  395. required=False
  396. )
  397. fieldsets = (
  398. (_('Config Context'), ('name', 'weight', 'description', 'data', 'is_active')),
  399. (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')),
  400. (_('Assignment'), (
  401. 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
  402. 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
  403. )),
  404. )
  405. class Meta:
  406. model = ConfigContext
  407. fields = (
  408. 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
  409. 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
  410. 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
  411. )
  412. def __init__(self, *args, initial=None, **kwargs):
  413. # Convert data delivered via initial data to JSON data
  414. if initial and 'data' in initial:
  415. if type(initial['data']) is str:
  416. initial['data'] = json.loads(initial['data'])
  417. super().__init__(*args, initial=initial, **kwargs)
  418. # Disable data field when a DataFile has been set
  419. if self.instance.data_file:
  420. self.fields['data'].widget.attrs['readonly'] = True
  421. self.fields['data'].help_text = _('Data is populated from the remote source selected below.')
  422. def clean(self):
  423. super().clean()
  424. if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'):
  425. raise forms.ValidationError(_("Must specify either local data or a data file"))
  426. return self.cleaned_data
  427. class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
  428. tags = DynamicModelMultipleChoiceField(
  429. label=_('Tags'),
  430. queryset=Tag.objects.all(),
  431. required=False
  432. )
  433. template_code = forms.CharField(
  434. label=_('Template code'),
  435. required=False,
  436. widget=forms.Textarea(attrs={'class': 'font-monospace'})
  437. )
  438. fieldsets = (
  439. (_('Config Template'), ('name', 'description', 'environment_params', 'tags')),
  440. (_('Content'), ('template_code',)),
  441. (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')),
  442. )
  443. class Meta:
  444. model = ConfigTemplate
  445. fields = '__all__'
  446. widgets = {
  447. 'environment_params': forms.Textarea(attrs={'rows': 5})
  448. }
  449. def __init__(self, *args, **kwargs):
  450. super().__init__(*args, **kwargs)
  451. # Disable content field when a DataFile has been set
  452. if self.instance.data_file:
  453. self.fields['template_code'].widget.attrs['readonly'] = True
  454. self.fields['template_code'].help_text = _(
  455. 'Template content is populated from the remote source selected below.'
  456. )
  457. def clean(self):
  458. super().clean()
  459. if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
  460. raise forms.ValidationError(_("Must specify either local content or a data file"))
  461. return self.cleaned_data
  462. class ImageAttachmentForm(forms.ModelForm):
  463. class Meta:
  464. model = ImageAttachment
  465. fields = [
  466. 'name', 'image',
  467. ]
  468. class JournalEntryForm(NetBoxModelForm):
  469. kind = forms.ChoiceField(
  470. label=_('Kind'),
  471. choices=add_blank_choice(JournalEntryKindChoices),
  472. required=False
  473. )
  474. comments = CommentField()
  475. class Meta:
  476. model = JournalEntry
  477. fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'tags', 'comments']
  478. widgets = {
  479. 'assigned_object_type': forms.HiddenInput,
  480. 'assigned_object_id': forms.HiddenInput,
  481. }