forms.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. from collections import OrderedDict
  2. from django import forms
  3. from django.contrib.auth.models import User
  4. from django.contrib.contenttypes.models import ContentType
  5. from django.core.exceptions import ObjectDoesNotExist
  6. from taggit.forms import TagField
  7. from dcim.models import DeviceRole, Platform, Region, Site
  8. from tenancy.models import Tenant, TenantGroup
  9. from utilities.forms import (
  10. add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
  11. CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
  12. SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
  13. )
  14. from .choices import *
  15. from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
  16. #
  17. # Custom fields
  18. #
  19. def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
  20. """
  21. Retrieve all CustomFields applicable to the given ContentType
  22. """
  23. field_dict = OrderedDict()
  24. custom_fields = CustomField.objects.filter(obj_type=content_type)
  25. if filterable_only:
  26. custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
  27. for cf in custom_fields:
  28. field_name = 'cf_{}'.format(str(cf.name))
  29. initial = cf.default if not bulk_edit else None
  30. # Integer
  31. if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
  32. field = forms.IntegerField(required=cf.required, initial=initial)
  33. # Boolean
  34. elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  35. choices = (
  36. (None, '---------'),
  37. (1, 'True'),
  38. (0, 'False'),
  39. )
  40. if initial is not None and initial.lower() in ['true', 'yes', '1']:
  41. initial = 1
  42. elif initial is not None and initial.lower() in ['false', 'no', '0']:
  43. initial = 0
  44. else:
  45. initial = None
  46. field = forms.NullBooleanField(
  47. required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
  48. )
  49. # Date
  50. elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
  51. field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
  52. # Select
  53. elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
  54. choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
  55. if not cf.required or bulk_edit or filterable_only:
  56. choices = [(None, '---------')] + choices
  57. # Check for a default choice
  58. default_choice = None
  59. if initial:
  60. try:
  61. default_choice = cf.choices.get(value=initial).pk
  62. except ObjectDoesNotExist:
  63. pass
  64. field = forms.TypedChoiceField(
  65. choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
  66. )
  67. # URL
  68. elif cf.type == CustomFieldTypeChoices.TYPE_URL:
  69. field = LaxURLField(required=cf.required, initial=initial)
  70. # Text
  71. else:
  72. field = forms.CharField(max_length=255, required=cf.required, initial=initial)
  73. field.model = cf
  74. field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
  75. if cf.description:
  76. field.help_text = cf.description
  77. field_dict[field_name] = field
  78. return field_dict
  79. class CustomFieldForm(forms.ModelForm):
  80. def __init__(self, *args, **kwargs):
  81. self.custom_fields = []
  82. self.obj_type = ContentType.objects.get_for_model(self._meta.model)
  83. super().__init__(*args, **kwargs)
  84. # Add all applicable CustomFields to the form
  85. custom_fields = []
  86. for name, field in get_custom_fields_for_model(self.obj_type).items():
  87. self.fields[name] = field
  88. custom_fields.append(name)
  89. self.custom_fields = custom_fields
  90. # If editing an existing object, initialize values for all custom fields
  91. if self.instance.pk:
  92. existing_values = CustomFieldValue.objects.filter(
  93. obj_type=self.obj_type,
  94. obj_id=self.instance.pk
  95. ).prefetch_related('field')
  96. for cfv in existing_values:
  97. self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
  98. def _save_custom_fields(self):
  99. for field_name in self.custom_fields:
  100. try:
  101. cfv = CustomFieldValue.objects.prefetch_related('field').get(
  102. field=self.fields[field_name].model,
  103. obj_type=self.obj_type,
  104. obj_id=self.instance.pk
  105. )
  106. except CustomFieldValue.DoesNotExist:
  107. # Skip this field if none exists already and its value is empty
  108. if self.cleaned_data[field_name] in [None, '']:
  109. continue
  110. cfv = CustomFieldValue(
  111. field=self.fields[field_name].model,
  112. obj_type=self.obj_type,
  113. obj_id=self.instance.pk
  114. )
  115. cfv.value = self.cleaned_data[field_name]
  116. cfv.save()
  117. def save(self, commit=True):
  118. obj = super().save(commit)
  119. # Handle custom fields the same way we do M2M fields
  120. if commit:
  121. self._save_custom_fields()
  122. else:
  123. self.save_custom_fields = self._save_custom_fields
  124. return obj
  125. class CustomFieldBulkEditForm(BulkEditForm):
  126. def __init__(self, *args, **kwargs):
  127. super().__init__(*args, **kwargs)
  128. self.custom_fields = []
  129. self.obj_type = ContentType.objects.get_for_model(self.model)
  130. # Add all applicable CustomFields to the form
  131. custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
  132. for name, field in custom_fields:
  133. # Annotate non-required custom fields as nullable
  134. if not field.required:
  135. self.nullable_fields.append(name)
  136. field.required = False
  137. self.fields[name] = field
  138. # Annotate this as a custom field
  139. self.custom_fields.append(name)
  140. class CustomFieldFilterForm(forms.Form):
  141. def __init__(self, *args, **kwargs):
  142. self.obj_type = ContentType.objects.get_for_model(self.model)
  143. super().__init__(*args, **kwargs)
  144. # Add all applicable CustomFields to the form
  145. custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
  146. for name, field in custom_fields:
  147. field.required = False
  148. self.fields[name] = field
  149. #
  150. # Tags
  151. #
  152. class TagForm(BootstrapMixin, forms.ModelForm):
  153. slug = SlugField()
  154. comments = CommentField()
  155. class Meta:
  156. model = Tag
  157. fields = [
  158. 'name', 'slug', 'color', 'comments'
  159. ]
  160. class AddRemoveTagsForm(forms.Form):
  161. def __init__(self, *args, **kwargs):
  162. super().__init__(*args, **kwargs)
  163. # Add add/remove tags fields
  164. self.fields['add_tags'] = TagField(required=False)
  165. self.fields['remove_tags'] = TagField(required=False)
  166. class TagFilterForm(BootstrapMixin, forms.Form):
  167. model = Tag
  168. q = forms.CharField(
  169. required=False,
  170. label='Search'
  171. )
  172. class TagBulkEditForm(BootstrapMixin, BulkEditForm):
  173. pk = forms.ModelMultipleChoiceField(
  174. queryset=Tag.objects.all(),
  175. widget=forms.MultipleHiddenInput
  176. )
  177. color = forms.CharField(
  178. max_length=6,
  179. required=False,
  180. widget=ColorSelect()
  181. )
  182. class Meta:
  183. nullable_fields = []
  184. #
  185. # Config contexts
  186. #
  187. class ConfigContextForm(BootstrapMixin, forms.ModelForm):
  188. tags = forms.ModelMultipleChoiceField(
  189. queryset=Tag.objects.all(),
  190. to_field_name='slug',
  191. required=False,
  192. widget=APISelectMultiple(
  193. api_url="/api/extras/tags/"
  194. )
  195. )
  196. data = JSONField(
  197. label=''
  198. )
  199. class Meta:
  200. model = ConfigContext
  201. fields = [
  202. 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
  203. 'tenants', 'tags', 'data',
  204. ]
  205. widgets = {
  206. 'regions': APISelectMultiple(
  207. api_url="/api/dcim/regions/"
  208. ),
  209. 'sites': APISelectMultiple(
  210. api_url="/api/dcim/sites/"
  211. ),
  212. 'roles': APISelectMultiple(
  213. api_url="/api/dcim/device-roles/"
  214. ),
  215. 'platforms': APISelectMultiple(
  216. api_url="/api/dcim/platforms/"
  217. ),
  218. 'tenant_groups': APISelectMultiple(
  219. api_url="/api/tenancy/tenant-groups/"
  220. ),
  221. 'tenants': APISelectMultiple(
  222. api_url="/api/tenancy/tenants/"
  223. ),
  224. }
  225. class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
  226. pk = forms.ModelMultipleChoiceField(
  227. queryset=ConfigContext.objects.all(),
  228. widget=forms.MultipleHiddenInput
  229. )
  230. weight = forms.IntegerField(
  231. required=False,
  232. min_value=0
  233. )
  234. is_active = forms.NullBooleanField(
  235. required=False,
  236. widget=BulkEditNullBooleanSelect()
  237. )
  238. description = forms.CharField(
  239. required=False,
  240. max_length=100
  241. )
  242. class Meta:
  243. nullable_fields = [
  244. 'description',
  245. ]
  246. class ConfigContextFilterForm(BootstrapMixin, forms.Form):
  247. q = forms.CharField(
  248. required=False,
  249. label='Search'
  250. )
  251. region = FilterChoiceField(
  252. queryset=Region.objects.all(),
  253. to_field_name='slug',
  254. widget=APISelectMultiple(
  255. api_url="/api/dcim/regions/",
  256. value_field="slug",
  257. )
  258. )
  259. site = FilterChoiceField(
  260. queryset=Site.objects.all(),
  261. to_field_name='slug',
  262. widget=APISelectMultiple(
  263. api_url="/api/dcim/sites/",
  264. value_field="slug",
  265. )
  266. )
  267. role = FilterChoiceField(
  268. queryset=DeviceRole.objects.all(),
  269. to_field_name='slug',
  270. widget=APISelectMultiple(
  271. api_url="/api/dcim/device-roles/",
  272. value_field="slug",
  273. )
  274. )
  275. platform = FilterChoiceField(
  276. queryset=Platform.objects.all(),
  277. to_field_name='slug',
  278. widget=APISelectMultiple(
  279. api_url="/api/dcim/platforms/",
  280. value_field="slug",
  281. )
  282. )
  283. tenant_group = FilterChoiceField(
  284. queryset=TenantGroup.objects.all(),
  285. to_field_name='slug',
  286. widget=APISelectMultiple(
  287. api_url="/api/tenancy/tenant-groups/",
  288. value_field="slug",
  289. )
  290. )
  291. tenant = FilterChoiceField(
  292. queryset=Tenant.objects.all(),
  293. to_field_name='slug',
  294. widget=APISelectMultiple(
  295. api_url="/api/tenancy/tenants/",
  296. value_field="slug",
  297. )
  298. )
  299. tag = FilterChoiceField(
  300. queryset=Tag.objects.all(),
  301. to_field_name='slug',
  302. widget=APISelectMultiple(
  303. api_url="/api/extras/tags/",
  304. value_field="slug",
  305. )
  306. )
  307. #
  308. # Filter form for local config context data
  309. #
  310. class LocalConfigContextFilterForm(forms.Form):
  311. local_context_data = forms.NullBooleanField(
  312. required=False,
  313. label='Has local config context data',
  314. widget=StaticSelect2(
  315. choices=BOOLEAN_WITH_BLANK_CHOICES
  316. )
  317. )
  318. #
  319. # Image attachments
  320. #
  321. class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
  322. class Meta:
  323. model = ImageAttachment
  324. fields = [
  325. 'name', 'image',
  326. ]
  327. #
  328. # Change logging
  329. #
  330. class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
  331. model = ObjectChange
  332. q = forms.CharField(
  333. required=False,
  334. label='Search'
  335. )
  336. time_after = forms.DateTimeField(
  337. label='After',
  338. required=False,
  339. widget=DateTimePicker()
  340. )
  341. time_before = forms.DateTimeField(
  342. label='Before',
  343. required=False,
  344. widget=DateTimePicker()
  345. )
  346. action = forms.ChoiceField(
  347. choices=add_blank_choice(ObjectChangeActionChoices),
  348. required=False
  349. )
  350. user = forms.ModelChoiceField(
  351. queryset=User.objects.order_by('username'),
  352. required=False
  353. )
  354. changed_object_type = forms.ModelChoiceField(
  355. queryset=ContentType.objects.order_by('model'),
  356. required=False,
  357. widget=ContentTypeSelect(),
  358. label='Object Type'
  359. )
  360. #
  361. # Scripts
  362. #
  363. class ScriptForm(BootstrapMixin, forms.Form):
  364. _commit = forms.BooleanField(
  365. required=False,
  366. initial=True,
  367. label="Commit changes",
  368. help_text="Commit changes to the database (uncheck for a dry-run)"
  369. )
  370. def __init__(self, vars, *args, commit_default=True, **kwargs):
  371. super().__init__(*args, **kwargs)
  372. # Dynamically populate fields for variables
  373. for name, var in vars.items():
  374. self.fields[name] = var.as_field()
  375. # Toggle default commit behavior based on Meta option
  376. if not commit_default:
  377. self.fields['_commit'].initial = False
  378. # Move _commit to the end of the form
  379. self.fields.move_to_end('_commit', True)
  380. @property
  381. def requires_input(self):
  382. """
  383. A boolean indicating whether the form requires user input (ignore the _commit field).
  384. """
  385. return bool(len(self.fields) > 1)