model_forms.py 19 KB

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