model_forms.py 19 KB

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