model_forms.py 17 KB

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