model_forms.py 19 KB

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